From 164c1331b5fd87e2bee87f82e33aa63bf2fcf881 Mon Sep 17 00:00:00 2001 From: beamologist Date: Sat, 26 Sep 2026 07:56:24 +0200 Subject: [PATCH 01/12] cbor: decode under macula 12's decoding rule, held to the shared vectors and the reference decoder's reasons Co-Authored-By: Claude Opus 5.5 --- src/cbor.rs | 754 ++++++------------ tests/cbor_decoding_rule.rs | 361 +++++++++ tests/vectors/README.md | 16 + tests/vectors/cbor/decoding_rule_v1.json | 310 +++++++ tests/vectors/frame/erlang_neighbour.json | 1 + tests/vectors/handshake/erlang_handshake.json | 21 + tests/vectors/identity/erlang_bindings.json | 54 ++ .../lamps_mldsa87_rsa4096_pss_sha512/m.bin | 1 + .../otp_message.bin | 1 + .../otp_pk.bin | Bin 0 -> 3118 bytes .../otp_sig.bin | Bin 0 -> 5139 bytes .../lamps_mldsa87_rsa4096_pss_sha512/pk.bin | Bin 0 -> 3118 bytes .../lamps_mldsa87_rsa4096_pss_sha512/s.bin | Bin 0 -> 5139 bytes .../zero_dropped_sig.bin | Bin 0 -> 5138 bytes tests/vectors/manifest/erlang_manifests.json | 1 + tests/vectors/record/own_namespace/README.md | 31 + .../pq_hybrid/org_without_chain.bin | Bin 0 -> 8549 bytes .../pq_hybrid/own_hex_without_a_name.bin | Bin 0 -> 8606 bytes .../record/own_namespace/pq_hybrid/own_ok.bin | Bin 0 -> 8611 bytes .../pq_hybrid/own_other_node.bin | Bin 0 -> 8611 bytes .../own_namespace/pq_hybrid/own_short_hex.bin | Bin 0 -> 8609 bytes .../pq_hybrid/own_uppercase_hex.bin | Bin 0 -> 8611 bytes .../pq_hybrid/own_with_authorization.bin | Bin 0 -> 8665 bytes .../pq_pure/org_without_chain.bin | Bin 0 -> 7505 bytes .../pq_pure/own_hex_without_a_name.bin | Bin 0 -> 7562 bytes .../record/own_namespace/pq_pure/own_ok.bin | Bin 0 -> 7567 bytes .../own_namespace/pq_pure/own_other_node.bin | Bin 0 -> 7567 bytes .../own_namespace/pq_pure/own_short_hex.bin | Bin 0 -> 7565 bytes .../pq_pure/own_uppercase_hex.bin | Bin 0 -> 7567 bytes .../pq_pure/own_with_authorization.bin | Bin 0 -> 7621 bytes .../record/own_namespace/verdicts.json | 115 +++ 31 files changed, 1152 insertions(+), 514 deletions(-) create mode 100644 tests/cbor_decoding_rule.rs create mode 100644 tests/vectors/README.md create mode 100644 tests/vectors/cbor/decoding_rule_v1.json create mode 100644 tests/vectors/frame/erlang_neighbour.json create mode 100644 tests/vectors/handshake/erlang_handshake.json create mode 100644 tests/vectors/identity/erlang_bindings.json create mode 100644 tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/m.bin create mode 100644 tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/otp_message.bin create mode 100644 tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/otp_pk.bin create mode 100644 tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/otp_sig.bin create mode 100644 tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/pk.bin create mode 100644 tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/s.bin create mode 100644 tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/zero_dropped_sig.bin create mode 100644 tests/vectors/manifest/erlang_manifests.json create mode 100644 tests/vectors/record/own_namespace/README.md create mode 100644 tests/vectors/record/own_namespace/pq_hybrid/org_without_chain.bin create mode 100644 tests/vectors/record/own_namespace/pq_hybrid/own_hex_without_a_name.bin create mode 100644 tests/vectors/record/own_namespace/pq_hybrid/own_ok.bin create mode 100644 tests/vectors/record/own_namespace/pq_hybrid/own_other_node.bin create mode 100644 tests/vectors/record/own_namespace/pq_hybrid/own_short_hex.bin create mode 100644 tests/vectors/record/own_namespace/pq_hybrid/own_uppercase_hex.bin create mode 100644 tests/vectors/record/own_namespace/pq_hybrid/own_with_authorization.bin create mode 100644 tests/vectors/record/own_namespace/pq_pure/org_without_chain.bin create mode 100644 tests/vectors/record/own_namespace/pq_pure/own_hex_without_a_name.bin create mode 100644 tests/vectors/record/own_namespace/pq_pure/own_ok.bin create mode 100644 tests/vectors/record/own_namespace/pq_pure/own_other_node.bin create mode 100644 tests/vectors/record/own_namespace/pq_pure/own_short_hex.bin create mode 100644 tests/vectors/record/own_namespace/pq_pure/own_uppercase_hex.bin create mode 100644 tests/vectors/record/own_namespace/pq_pure/own_with_authorization.bin create mode 100644 tests/vectors/record/own_namespace/verdicts.json diff --git a/src/cbor.rs b/src/cbor.rs index 0de5390..750ce93 100644 --- a/src/cbor.rs +++ b/src/cbor.rs @@ -5,12 +5,11 @@ //! a direct Rust transcription of the hand-rolled canonical encoder macula //! actually ships in `native/macula_cbor_nif/src/deterministic.rs` //! (`macula-io/macula`), which `macula_frame.erl`'s wire codec calls as -//! `pack_deterministic/1` / `unpack_deterministic/1`. Every frame's -//! Ed25519 signature is computed over these exact bytes, so a divergence +//! `pack_deterministic/1` / `unpack_deterministic/1`. Every signed frame, +//! record and binding is signed over these exact bytes, so a divergence //! here silently breaks signature verification against real stations — //! this module's tests include fixtures captured directly from the real -//! NIF (`rebar3 shell` against `macula-io/macula` at v10.10.0), not just -//! hand-derived expectations. +//! NIF, not just hand-derived expectations. //! //! Encoding rules (all verified against the reference, see `tests` below): //! - Integers: minimal-length encoding (inline for 0..=23, else the @@ -40,12 +39,16 @@ //! "canonical CBOR" crate that follows the RFC's shortest-float rule //! would silently produce non-matching, non-verifying bytes here. //! -//! Decode is deliberately narrow to match the reference: major type 6 -//! (tags) is rejected outright, and major 7 only supports `null` and the -//! three float widths (binary16/32/64, all promoted to `f64`) — no -//! booleans, no "undefined" simple value. Every read is bounds-checked; -//! nothing in this module panics on malformed or truncated input, since -//! decode exists specifically to parse untrusted, network-received bytes. +//! Decode applies macula 12's decoding rule, the rule every stack applies to +//! what a peer sends (`tests/cbor_decoding_rule.rs` holds it to the shared +//! vectors and to the reason macula's reference decoder gives for each +//! refusal): lengths in any width, map keys in any order but only text or +//! integers and never twice, integers within -2^63..=2^63-1, `null` and +//! finite half, single and double floats, at most [`MAX_NESTING_DEPTH`] +//! levels and [`MAX_ELEMENTS`] items. Tags, booleans and every other simple +//! value are refused. Every read is bounds-checked; nothing in this module +//! panics on malformed or truncated input, since decode exists specifically +//! to parse untrusted, network-received bytes. use std::fmt; @@ -62,8 +65,7 @@ pub enum Value { Text(String), List(Vec), /// Insertion order on construction; canonical key sort happens at - /// encode time, not here. Decode preserves last-write-wins on - /// duplicate keys, matching the reference decoder exactly. + /// encode time, not here. Decode refuses a duplicate key. Map(Vec<(Value, Value)>), Null, /// Always round-trips through binary64 — see the module doc's note @@ -137,71 +139,44 @@ impl fmt::Display for IntOutOfRange { impl std::error::Error for IntOutOfRange {} +/// Why [`decode`] refused an input: one variant for each reason macula's +/// reference decoder (`macula_record_cbor:decode_strict/1`) gives, so an input +/// is refused for the same reason in every stack. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DecodeError { - /// The buffer ended before a complete value could be read. - Truncated, - /// Major type 6 (tags) — not part of macula's wire format. - UnsupportedMajorType(u8), - /// A major-7 additional-info value with no meaning here (only 22 - /// \[null\] and 25/26/27 \[floats\] are supported). - UnsupportedAdditionalInfo(u8), - /// Additional-info 28-31 on any major type — reserved, unused. - UnsupportedAdditionalInfoEncoding(u8), - /// A single top-level value didn't consume the whole buffer. + /// Bytes after the top-level value. TrailingBytes, - /// A major-3 (text) value's bytes were not valid UTF-8. The reference - /// Erlang/Rust codec does not validate this on decode (it stores - /// whatever bytes arrived); this port deliberately diverges and - /// treats it as an error instead of losslessly carrying invalid - /// UTF-8, since every real macula text value is ASCII/UTF-8 by - /// construction and failing closed on malformed input from a peer is - /// the safer default. Documented, not accidental. - InvalidUtf8, - /// A half-float (binary16) with exponent 31 — NaN or infinity, which - /// has no representation as an ordinary `f64` value here (matches - /// the reference decoder's own behavior: no clause for it). - UnrepresentableFloat, - /// Lists/maps nested more than [`MAX_NESTING_DEPTH`] levels deep. - /// Not part of the wire format's own semantics — a defense against a - /// maliciously crafted frame: a list-of-one-list-of-one-list... can - /// encode extreme nesting in very few bytes (one byte per level), - /// and this decoder is plain recursive descent, so without a limit - /// a peer could crash the process with a stack overflow (not a - /// catchable panic) from a single frame well under - /// `frame::MAX_FRAME_BYTES`. No real macula wire value nests anywhere - /// close to this deep. + /// A map key that is neither text nor an integer. + BadKey, + /// A map key equal to an earlier key of its map: text with the same + /// bytes, or an integer of the same value, in any width. + DuplicateKey, + /// Text that is not valid UTF-8. + InvalidText, + /// Arrays and maps nested more than [`MAX_NESTING_DEPTH`] levels. NestingTooDeep, + /// An integer below -2^63 or above 2^63-1. + IntegerOutOfRange, + /// Input that holds more than [`MAX_ELEMENTS`] items. + TooManyElements, + /// Input that is not one complete item of what the rule allows: truncated + /// input, an indefinite length, a tag, a simple value other than null, or + /// a float that is NaN or infinite. + Malformed, } impl fmt::Display for DecodeError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - DecodeError::Truncated => write!(f, "truncated input"), - DecodeError::UnsupportedMajorType(m) => { - write!( - f, - "unsupported major type {m} (only 0-5 and 7 are valid here)" - ) - } - DecodeError::UnsupportedAdditionalInfo(ai) => { - write!(f, "unsupported major-7 additional info {ai}") - } - DecodeError::UnsupportedAdditionalInfoEncoding(ai) => { - write!( - f, - "unsupported additional-info encoding {ai} (28-31 are reserved)" - ) - } - DecodeError::TrailingBytes => write!(f, "trailing bytes after the top-level value"), - DecodeError::InvalidUtf8 => write!(f, "text value was not valid UTF-8"), - DecodeError::UnrepresentableFloat => { - write!(f, "half-float NaN/infinity has no f64 representation here") - } - DecodeError::NestingTooDeep => { - write!(f, "list/map nesting exceeds {MAX_NESTING_DEPTH} levels") - } - } + f.write_str(match self { + DecodeError::TrailingBytes => "bytes after the top-level value", + DecodeError::BadKey => "a map key that is neither text nor an integer", + DecodeError::DuplicateKey => "a duplicate map key", + DecodeError::InvalidText => "text that is not valid UTF-8", + DecodeError::NestingTooDeep => "arrays and maps nested more than 64 levels", + DecodeError::IntegerOutOfRange => "an integer below -2^63 or above 2^63-1", + DecodeError::TooManyElements => "more than 131072 items", + DecodeError::Malformed => "malformed", + }) } } @@ -311,325 +286,234 @@ fn encode_head(major: u8, n: u64, out: &mut Vec) { } } -/// Recursive-descent nesting limit — see [`DecodeError::NestingTooDeep`] -/// for why this exists. No real macula wire value nests remotely this -/// deep; this only ever rejects an adversarial input. -pub const MAX_NESTING_DEPTH: usize = 128; - -/// Decode a single deterministic-CBOR value from `bytes`. The whole -/// buffer must be consumed by exactly one top-level value — trailing -/// bytes are an error, matching the reference decoder's own contract. +/// How many arrays and maps may nest inside each other, the outermost +/// counted: 64 levels decode, and a 65th is refused, as in macula's decoding +/// rule. +pub const MAX_NESTING_DEPTH: usize = 64; + +/// How many CBOR items one [`decode`] may read: every item counts once, the +/// top-level value, array elements, map keys and map values included. It is +/// macula's element budget, so an input macula refuses for the items it holds +/// is refused here too. +pub const MAX_ELEMENTS: usize = 131_072; + +/// Decode `bytes` as exactly one value under macula's post-quantum decoding +/// rule, the rule every stack applies to what a peer sends. Lengths are +/// accepted in any width, map keys in any order, and half, single and double +/// floats; everything else the rule refuses is refused with the reason +/// macula's reference decoder gives. Every path returns an error rather than +/// panicking, since the input is untrusted. pub fn decode(bytes: &[u8]) -> Result { - // Nobody consumes the top-level value's canonical bytes — don't - // build them (see `decode_one`'s `need_canon` param). - let (value, _canonical_bytes, pos) = decode_one(bytes, 0, 0, false)?; - if pos != bytes.len() { + let mut decoder = Decoder { + data: bytes, + pos: 0, + budget: MAX_ELEMENTS, + }; + let value = decoder.item(0)?; + if decoder.pos != bytes.len() { return Err(DecodeError::TrailingBytes); } Ok(value) } -fn need(buf: &[u8], pos: usize, n: usize) -> Result<(), DecodeError> { - match pos.checked_add(n) { - Some(end) if end <= buf.len() => Ok(()), - _ => Err(DecodeError::Truncated), - } -} - -/// Decodes one value, and — only when `need_canon` is true — its own -/// canonical (deterministic-CBOR) bytes, built bottom-up as decoding -/// proceeds rather than re-derived by a separate encode pass afterward. -/// See `decode_map`'s doc for why the bytes are needed at all (a map -/// using another map as a key needs its key's canonical bytes to -/// dedupe/sort by, and re-encoding a key from scratch at every ancestor -/// level is itself an unbounded-work trap on nested input) and why -/// `need_canon` exists (computing them for every value regardless of -/// whether anything ever reads them — the common case, since most -/// decoded values are never used as a map key at any level — turned out -/// to be its own real cost: a value nested `depth` levels inside a -/// value that never touches a map key at all still doesn't need canon -/// bytes, but always building them anyway meant a large nested -/// non-map-keyed value paid full canon-construction cost with nothing -/// to show for it, confirmed to regress both time and peak memory on -/// large deep lists/values with no map keys anywhere in them). -/// `decode_map` is the only caller that ever passes different values -/// for its two child calls: always `true` for a key (dedup needs it -/// unconditionally, regardless of whether the map's OWN canon bytes are -/// wanted) and its own `need_canon` for a value (only needed if this -/// whole map is itself nested inside some ancestor's key). -fn decode_one( - buf: &[u8], +/// Reads one value from `data`: `pos` is how far it has read, and `budget` +/// how many more items it may read. +struct Decoder<'a> { + data: &'a [u8], pos: usize, - depth: usize, - need_canon: bool, -) -> Result<(Value, Vec, usize), DecodeError> { - if depth > MAX_NESTING_DEPTH { - return Err(DecodeError::NestingTooDeep); - } - need(buf, pos, 1)?; - let byte0 = buf[pos]; - let major = byte0 >> 5; - let ai = byte0 & 0x1F; - - if major == 7 { - let (value, next) = decode_major7(buf, pos, ai)?; - return Ok(scalar_canonical_bytes(value, next, need_canon)); - } - - let (n, next) = decode_count(buf, pos + 1, ai)?; - match major { - 0 => Ok(scalar_canonical_bytes( - Value::Int(n as i128), - next, - need_canon, - )), - 1 => Ok(scalar_canonical_bytes( - Value::Int(-1i128 - n as i128), - next, - need_canon, - )), - 2 => { - let len = n as usize; - need(buf, next, len)?; - let value = Value::Bytes(buf[next..next + len].to_vec()); - Ok(scalar_canonical_bytes(value, next + len, need_canon)) - } - 3 => { - let len = n as usize; - need(buf, next, len)?; - let text = String::from_utf8(buf[next..next + len].to_vec()) - .map_err(|_| DecodeError::InvalidUtf8)?; - Ok(scalar_canonical_bytes( - Value::Text(text), - next + len, - need_canon, - )) - } - 4 => decode_list(buf, next, n, depth + 1, need_canon), - 5 => decode_map(buf, next, n, depth + 1, need_canon), - _ => Err(DecodeError::UnsupportedMajorType(major)), - } + budget: usize, } -/// `with_canonical_bytes`, but skipped (an empty `Vec` instead) when -/// nothing will ever read it — see `decode_one`'s `need_canon` doc. -fn scalar_canonical_bytes(value: Value, next: usize, need_canon: bool) -> (Value, Vec, usize) { - if need_canon { - with_canonical_bytes(value, next) - } else { - (value, Vec::new(), next) - } -} - -/// Computes a scalar (non-list/map) value's own canonical bytes via a -/// plain, non-recursive `encode_value` call — cheap regardless of where -/// in a nested structure it's called from, unlike `List`/`Map`, which -/// build their canonical bytes by concatenating their CHILDREN's -/// already-computed bytes (see `decode_list`/`decode_map`) instead of -/// calling `encode_value` on themselves. -fn with_canonical_bytes(value: Value, next: usize) -> (Value, Vec, usize) { - let mut canon = Vec::new(); - encode_value(&value, &mut canon).expect("a value produced by this decoder is always encodable"); - (value, canon, next) +/// A map key's identity under the rule: its text, or an integer's value. +#[derive(PartialEq, Eq, Hash)] +enum KeyId { + Text(String), + Int(i128), } -fn decode_count(buf: &[u8], pos: usize, ai: u8) -> Result<(u64, usize), DecodeError> { - match ai { - 0..=23 => Ok((ai as u64, pos)), - 24 => { - need(buf, pos, 1)?; - Ok((buf[pos] as u64, pos + 1)) +/// The room a list or map is given before its elements decode: a declared +/// count is not checked against the input, so it is never trusted as an +/// allocation size. +const MAX_SIZE_HINT: usize = 4; + +impl Decoder<'_> { + /// The item at `pos`, which sits inside `depth` arrays and maps. As in + /// macula's decoder, an item is counted against the budget once its head + /// and argument have been read, and before its own checks. + fn item(&mut self, depth: usize) -> Result { + let head = self.take(1)?[0]; + let (major, ai) = (head >> 5, head & 0x1F); + if major == 7 { + return self.simple_or_float(ai); } - 25 => { - need(buf, pos, 2)?; - Ok((u16::from_be_bytes([buf[pos], buf[pos + 1]]) as u64, pos + 2)) - } - 26 => { - need(buf, pos, 4)?; - let b: [u8; 4] = buf[pos..pos + 4].try_into().expect("checked len"); - Ok((u32::from_be_bytes(b) as u64, pos + 4)) - } - 27 => { - need(buf, pos, 8)?; - let b: [u8; 8] = buf[pos..pos + 8].try_into().expect("checked len"); - Ok((u64::from_be_bytes(b), pos + 8)) + let arg = self.argument(ai)?; + self.count()?; + match major { + 0 => integer(i128::from(arg), arg), + 1 => integer(-1 - i128::from(arg), arg), + 2 => Ok(Value::Bytes(self.take(arg)?.to_vec())), + 3 => { + let bytes = self.take(arg)?; + std::str::from_utf8(bytes) + .map(|text| Value::Text(text.to_owned())) + .map_err(|_| DecodeError::InvalidText) + } + 4 => self.list(arg, depth), + 5 => self.map(arg, depth), + _ => Err(DecodeError::Malformed), } - 28..=31 => Err(DecodeError::UnsupportedAdditionalInfoEncoding(ai)), - _ => unreachable!("additional info is a 5-bit field, 0..=31"), } -} -fn decode_major7(buf: &[u8], pos: usize, ai: u8) -> Result<(Value, usize), DecodeError> { - match ai { - 22 => Ok((Value::Null, pos + 1)), - 25 => { - need(buf, pos + 1, 2)?; - let half = u16::from_be_bytes([buf[pos + 1], buf[pos + 2]]); - Ok((Value::Float(half_to_f64(half)?), pos + 3)) + /// Takes one item from the budget. + fn count(&mut self) -> Result<(), DecodeError> { + if self.budget == 0 { + return Err(DecodeError::TooManyElements); } - 26 => { - need(buf, pos + 1, 4)?; - let b: [u8; 4] = buf[pos + 1..pos + 5].try_into().expect("checked len"); - Ok((Value::Float(f32::from_be_bytes(b) as f64), pos + 5)) + self.budget -= 1; + Ok(()) + } + + /// The next `n` bytes of the input, moving past them. + fn take(&mut self, n: u64) -> Result<&[u8], DecodeError> { + let remaining = (self.data.len() - self.pos) as u64; + if n > remaining { + return Err(DecodeError::Malformed); } - 27 => { - need(buf, pos + 1, 8)?; - let b: [u8; 8] = buf[pos + 1..pos + 9].try_into().expect("checked len"); - Ok((Value::Float(f64::from_be_bytes(b)), pos + 9)) + let start = self.pos; + self.pos += n as usize; + Ok(&self.data[start..self.pos]) + } + + /// A head's argument, a value or a length: its additional information + /// itself up to 23, or the 1, 2, 4 or 8 bytes after the head, in whichever + /// width the sender chose. 28 to 31, every indefinite length among them, + /// is malformed. + fn argument(&mut self, ai: u8) -> Result { + let width = match ai { + 0..=23 => return Ok(u64::from(ai)), + 24 => 1, + 25 => 2, + 26 => 4, + 27 => 8, + _ => return Err(DecodeError::Malformed), + }; + Ok(self + .take(width)? + .iter() + .fold(0u64, |arg, &b| (arg << 8) | u64::from(b))) + } + + /// Major type 7, counted once its bytes have been read: null, or a finite + /// half, single or double float. Every other simple value, a boolean + /// among them, is malformed. + fn simple_or_float(&mut self, ai: u8) -> Result { + match ai { + 22 => { + self.count()?; + Ok(Value::Null) + } + 25 | 26 | 27 => { + let width = match ai { + 25 => 2, + 26 => 4, + _ => 8, + }; + let bytes = self.take(width)?; + let value = match bytes.len() { + 2 => half_to_f64(u16::from_be_bytes([bytes[0], bytes[1]])), + 4 => f64::from(f32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])), + _ => f64::from_be_bytes(bytes.try_into().map_err(|_| DecodeError::Malformed)?), + }; + self.count()?; + if value.is_finite() { + Ok(Value::Float(value)) + } else { + Err(DecodeError::Malformed) + } + } + 0..=24 => { + self.argument(ai)?; + self.count()?; + Err(DecodeError::Malformed) + } + _ => Err(DecodeError::Malformed), } - _ => Err(DecodeError::UnsupportedAdditionalInfo(ai)), } -} -fn decode_list( - buf: &[u8], - mut pos: usize, - count: u64, - depth: usize, - need_canon: bool, -) -> Result<(Value, Vec, usize), DecodeError> { - let mut items = Vec::with_capacity(count.min(1024) as usize); - let mut canon = Vec::new(); - if need_canon { - encode_head(4, count, &mut canon); + /// The room to give a list or map that declares `count` elements of at + /// least `items_per_element` items and bytes each: at most the count, + /// what the bytes and budget left could hold, and [`MAX_SIZE_HINT`], so + /// what decoding allocates follows the bytes present. + fn size_hint(&self, count: u64, items_per_element: usize) -> usize { + let bytes_left = (self.data.len() - self.pos) / items_per_element; + let budget_left = self.budget / items_per_element; + count + .min(bytes_left as u64) + .min(budget_left as u64) + .min(MAX_SIZE_HINT as u64) as usize } - for _ in 0..count { - let (item, item_canon, next) = decode_one(buf, pos, depth, need_canon)?; - if need_canon { - canon.extend_from_slice(&item_canon); + + fn list(&mut self, count: u64, depth: usize) -> Result { + if depth >= MAX_NESTING_DEPTH { + return Err(DecodeError::NestingTooDeep); } - items.push(item); - pos = next; + let mut items = Vec::with_capacity(self.size_hint(count, 1)); + for _ in 0..count { + items.push(self.item(depth + 1)?); + } + Ok(Value::List(items)) } - Ok((Value::List(items), canon, pos)) -} -/// Duplicate keys overwrite (last write wins), matching the reference -/// decoder exactly — not treated as an error. -/// -/// Looks up each key's slot by its own canonical bytes (from -/// `decode_one`'s bottom-up construction — see that function's doc) in -/// a `HashMap`, rather than a `Value`-equality linear scan over -/// everything decoded so far: the scan made this function O(n²) on a -/// map with many distinct keys — a single ~350 KB crafted frame (well -/// under `frame::MAX_FRAME_BYTES`) pegged a CPU core for 50+ seconds -/// decoding it, and the cost scaled quadratically toward the -/// frame-size cap, all of it running before any signature check on the -/// frame. -/// -/// An earlier version of this fix looked up each key by calling -/// `encode(&k)` fresh, per entry, instead of reusing the bytes -/// `decode_one` already built while decoding that same key — that's -/// sound for a FLAT map (fixed the 350 KB/50 s case, confirmed -/// empirically), but reintroduced unbounded work for a map whose KEY is -/// itself a large nested structure: re-encoding a key from scratch at -/// every ancestor level costs O(depth × key size), and a 128-level -/// chain of single-entry maps (`MAX_NESTING_DEPTH`) each keyed by a -/// large blob turned back into tens of seconds of pre-auth CPU on a -/// frame still under the size cap — confirmed empirically. Building -/// canonical bytes bottom-up (each value's bytes computed exactly once, -/// when it's decoded, then only ever concatenated/sorted by its -/// ancestors — never re-derived) fixed that: the same 128-deep/15 MB -/// case dropped from ~31 s to ~1 s. This is O(depth × size), the same -/// bound `MAX_NESTING_DEPTH` already exists to enforce — NOT O(total -/// input size) regardless of nesting shape, since a key containing a -/// key still gets its bytes copied once per level it's nested under. -/// It just can no longer exceed the depth cap's own bound, the same -/// guarantee `NestingTooDeep` already gives the rest of this decoder. -/// -/// Computing canon bytes unconditionally for every value (not just -/// values that end up under a map key somewhere) was ALSO measured to -/// be a real, separate cost — a large nested value that never touches -/// a map key still paid full canon-construction cost for nothing; -/// `need_canon` (threaded through `decode_one`/`decode_list`/this -/// function) skips it. A key's canon is always needed, unconditionally -/// (dedup requires it); a value's is only needed if this whole map is -/// itself nested inside some ancestor's key, i.e. this map's OWN -/// `need_canon`. -/// -/// The still-remaining, deliberate, narrow divergences from a literal -/// `Value`-equality scan, fuzzed against 500k adversarial inputs -/// against both this and the pre-fix decoder: nested-map keys that -/// differ only in wire insertion order now merge (the old scan kept -/// both — wrong, since Erlang maps/this format's own key-sort are both -/// unordered); `+0.0`/`-0.0` keys no longer merge (the old scan merged -/// them via `PartialEq` — wrong, since neither Erlang's `=:=` nor the -/// reference NIF's own byte-dedup merge them); bit-identical `NaN` keys -/// now merge (the old scan never did, since `NaN != NaN` under -/// `PartialEq` — matches the reference). All three move this decoder -/// TOWARD the reference decoder's actual behavior, not away from it, -/// and none of the three is reachable in practice: no real macula map -/// key is ever a float or a nested map. -fn decode_map( - buf: &[u8], - mut pos: usize, - count: u64, - depth: usize, - need_canon: bool, -) -> Result<(Value, Vec, usize), DecodeError> { - let capacity = count.min(1024) as usize; - let mut pairs: Vec<(Value, Value)> = Vec::with_capacity(capacity); - // Owns each distinct key's canonical bytes (moved in on first sight, - // never cloned) -> slot index into `pairs`/`vals_canon`. - let mut index_of_key: std::collections::HashMap, usize> = - std::collections::HashMap::with_capacity(capacity); - // Per-slot VALUE canon, kept in step with `pairs` (same index, same - // last-write-wins updates) -- only populated when `need_canon`, since - // a key's canon (owned by `index_of_key` above) is the only one ever - // needed just to make dedup itself work. - let mut vals_canon: Vec> = Vec::with_capacity(if need_canon { capacity } else { 0 }); - for _ in 0..count { - // A key ALWAYS needs its canon bytes -- that's the dedup - // identity itself, independent of whether this map's OWN canon - // bytes (built below) are ever going to be read by anything. - let (k, key_canon, next1) = decode_one(buf, pos, depth, true)?; - let (v, val_canon, next2) = decode_one(buf, next1, depth, need_canon)?; - pos = next2; - use std::collections::hash_map::Entry; - match index_of_key.entry(key_canon) { - Entry::Occupied(e) => { - let i = *e.get(); - pairs[i].1 = v; - if need_canon { - vals_canon[i] = val_canon; - } - } - Entry::Vacant(e) => { - e.insert(pairs.len()); - pairs.push((k, v)); - if need_canon { - vals_canon.push(val_canon); - } + /// A map of `count` entries. Each entry's value decodes before its key is + /// judged, as in the reference decoder, so an input that breaks two + /// checks is refused for the same one in every stack. Duplicates are found + /// through a hash set, so the work grows with the number of keys, not its + /// square. + fn map(&mut self, count: u64, depth: usize) -> Result { + if depth >= MAX_NESTING_DEPTH { + return Err(DecodeError::NestingTooDeep); + } + let hint = self.size_hint(count, 2); + let mut pairs = Vec::with_capacity(hint); + let mut seen = std::collections::HashSet::with_capacity(hint); + for _ in 0..count { + let key = self.item(depth + 1)?; + let value = self.item(depth + 1)?; + let id = match &key { + Value::Text(text) => KeyId::Text(text.clone()), + Value::Int(n) => KeyId::Int(*n), + _ => return Err(DecodeError::BadKey), + }; + if !seen.insert(id) { + return Err(DecodeError::DuplicateKey); } + pairs.push((key, value)); } + Ok(Value::Map(pairs)) } - if !need_canon { - return Ok((Value::Map(pairs), Vec::new(), pos)); - } - // Matches `encode_map`'s own rule exactly: sort entries by the - // key's encoded bytes, plain lexicographic `Ord` on `Vec`. - let mut order: Vec<(&Vec, usize)> = index_of_key.iter().map(|(k, &i)| (k, i)).collect(); - order.sort_by(|a, b| a.0.cmp(b.0)); - let mut canon = Vec::new(); - encode_head(5, order.len() as u64, &mut canon); - for (k, i) in order { - canon.extend_from_slice(k); - canon.extend_from_slice(&vals_canon[i]); +} + +/// An integer head's value, refused when its argument puts it outside -2^63 +/// to 2^63-1: an unsigned argument of 2^63 or more is above 2^63-1, and a +/// negative one of 2^63 or more is below -2^63. +fn integer(value: i128, arg: u64) -> Result { + if arg >= 1 << 63 { + return Err(DecodeError::IntegerOutOfRange); } - Ok((Value::Map(pairs), canon, pos)) + Ok(Value::Int(value)) } -/// IEEE 754 binary16 → f64. Subnormals (exp=0) and normals (1..=30) use -/// the standard formula; exp=31 (NaN/infinity) has no representation here -/// — matches the reference decoder, which has no clause for it either. -fn half_to_f64(half: u16) -> Result { - let sign: f64 = if (half >> 15) & 1 == 1 { -1.0 } else { 1.0 }; +/// IEEE 754 binary16 to f64, infinities and NaN included; the caller refuses +/// what is not finite. +fn half_to_f64(half: u16) -> f64 { + let sign = if half >> 15 == 1 { -1.0 } else { 1.0 }; let exp = (half >> 10) & 0x1F; - let frac = (half & 0x3FF) as f64; + let frac = f64::from(half & 0x3FF); match exp { - 0 => Ok(sign * 2f64.powi(-14) * (frac / 1024.0)), - 1..=30 => Ok(sign * 2f64.powi(exp as i32 - 15) * (1.0 + frac / 1024.0)), - _ => Err(DecodeError::UnrepresentableFloat), + 0 => sign * 2f64.powi(-24) * frac, + 31 if frac == 0.0 => sign * f64::INFINITY, + 31 => f64::NAN, + _ => sign * 2f64.powi(i32::from(exp) - 15) * (1.0 + frac / 1024.0), } } @@ -814,117 +698,12 @@ mod tests { ); } - #[test] - fn decode_rejects_tags() { - // Major type 6, additional info 0 — a tag, not part of this wire - // format. - assert_eq!(decode(&[0xC0]), Err(DecodeError::UnsupportedMajorType(6))); - } - #[test] fn decode_rejects_trailing_bytes() { // A valid `0` (0x00) followed by a stray byte. assert_eq!(decode(&[0x00, 0xFF]), Err(DecodeError::TrailingBytes)); } - #[test] - fn decode_rejects_truncated_input() { - // Major 0, AI 24 (one more byte expected) but the buffer ends. - assert_eq!(decode(&[0x18]), Err(DecodeError::Truncated)); - } - - /// Builds a payload of `depth` one-element-list wrappers (major 4, - /// AI 1 — a single byte, `0x81`, per level) around one terminal - /// scalar (`0x00`, the integer 0). Before `MAX_NESTING_DEPTH` existed, - /// decoding this crashed the whole process with a real stack - /// overflow (verified against this exact decoder pre-fix, on a - /// realistic 2 MiB worker-thread stack, at a nesting depth of only - /// 100_000 -- well under 1% of what a single 16 MiB wire frame could - /// carry) rather than returning a decode error. A stack overflow - /// aborts the process; it is not a `panic!` `#[should_panic]` can - /// catch, so the tests below only exercise the now-clean error path. - fn nested_list_payload(depth: usize) -> Vec { - let mut buf = vec![0x81u8; depth]; - buf.push(0x00); - buf - } - - #[test] - fn decode_accepts_nesting_at_the_depth_limit() { - let bytes = nested_list_payload(MAX_NESTING_DEPTH); - assert!(decode(&bytes).is_ok()); - } - - #[test] - fn decode_rejects_nesting_one_past_the_depth_limit() { - let bytes = nested_list_payload(MAX_NESTING_DEPTH + 1); - assert_eq!(decode(&bytes), Err(DecodeError::NestingTooDeep)); - } - - #[test] - fn decode_rejects_extreme_nesting_without_crashing() { - // Far beyond the limit, and far beyond what actually crashed the - // pre-fix decoder -- this is the direct regression test for the - // stack-overflow finding. If this test process crashes instead of - // completing, the depth guard has regressed. - let bytes = nested_list_payload(100_000); - assert_eq!(decode(&bytes), Err(DecodeError::NestingTooDeep)); - } - - #[test] - fn decode_duplicate_map_keys_last_write_wins() { - // Two entries both keyed "a" (0x61 0x61), values 1 then 2. - let bytes = hex("A2616101616102"); - let decoded = decode(&bytes).expect("valid map"); - match decoded { - Value::Map(pairs) => { - assert_eq!(pairs.len(), 1); - assert_eq!(pairs[0], (Value::text("a"), Value::Int(2))); - } - other => panic!("expected a map, got {other:?}"), - } - } - - /// A duplicate key in the middle of several distinct ones overwrites - /// in place — the duplicate's ORIGINAL insertion slot, not a new one - /// appended at the end — and every other key's position is - /// undisturbed. Guards `decode_map`'s HashMap-indexed dedup: it would - /// be easy for a faster implementation to accidentally reorder - /// entries or dedupe the wrong slot. - #[test] - fn decode_duplicate_map_key_overwrites_its_original_slot_not_the_end() { - let map = Value::Map(vec![ - (Value::text("a"), Value::Int(1)), - (Value::text("b"), Value::Int(2)), - (Value::text("c"), Value::Int(3)), - ]); - let mut bytes = encode(&map).expect("encodable"); - // Append one more entry, "b" -> 99, so the wire form has 4 - // entries with "b" duplicated -- can't build this through - // `encode` directly since it only ever emits already-deduped - // maps; construct the extra entry's bytes by hand and bump the - // map's own entry count (the map header's low nibble, byte 0). - assert_eq!(bytes[0] & 0x1F, 3, "expected a 3-entry map header"); - bytes[0] = (bytes[0] & 0xE0) | 4; - bytes.extend_from_slice(&encode(&Value::text("b")).unwrap()); - bytes.extend_from_slice(&encode(&Value::Int(99)).unwrap()); - - let decoded = decode(&bytes).expect("valid map"); - match decoded { - Value::Map(pairs) => { - assert_eq!( - pairs, - vec![ - (Value::text("a"), Value::Int(1)), - (Value::text("b"), Value::Int(99)), - (Value::text("c"), Value::Int(3)), - ] - ); - } - other => panic!("expected a map, got {other:?}"), - } - } - /// Regression guard for a real bug: `decode_map`'s duplicate-key /// check used to be a `Value`-equality linear scan over every entry /// decoded so far, making decode O(n^2) in entry count. A single @@ -959,59 +738,6 @@ mod tests { ); } - /// A second, narrower regression this same bug had once already: - /// the first attempt at fixing the flat-map O(n^2) case above - /// re-encoded each key fresh (`encode(&k)`) to find its slot, which - /// fixed the flat case but reintroduced unbounded work for a map - /// whose KEY is itself a large nested structure -- re-encoding a - /// key from scratch at every ancestor level costs O(depth × key - /// size), and a `MAX_NESTING_DEPTH`-deep chain of single-entry maps - /// keyed by a large blob took real, measured tens of seconds even - /// though it's well under `frame::MAX_FRAME_BYTES`. This decodes a - /// nesting-depth-limit-deep chain wrapping a multi-megabyte blob key - /// in well under a second; if key canonicalization regresses to - /// re-deriving a key's bytes at every ancestor level instead of - /// reusing what decoding that key already computed, this test will - /// time out long before it fails its assertions. - #[test] - fn decode_map_with_a_large_deeply_nested_key_is_not_quadratic_in_depth() { - // `MAX_NESTING_DEPTH` copies of "a 1-entry map wrapping...", - // around one 4 MiB byte-string key, each level's own map then - // valued at `Int(0)` (innermost first). - let blob_len = 512 * 1024; - let mut bytes = vec![0xA1u8; MAX_NESTING_DEPTH]; - bytes.push(0x5A); // major 2 (bytes), AI 26 -> 4-byte length follows - bytes.extend_from_slice(&(blob_len as u32).to_be_bytes()); - bytes.extend(std::iter::repeat_n(0x41u8, blob_len)); - bytes.extend(std::iter::repeat_n(0x00u8, MAX_NESTING_DEPTH)); - - let start = std::time::Instant::now(); - let decoded = decode(&bytes).expect("valid, maximally-nested map-key chain"); - let elapsed = start.elapsed(); - - // Sanity: really did decode the full nested-map chain down to - // the 4 MiB blob at its center, not bail out early on a - // malformed payload. `0xA1` nests a 1-entry map as each level's - // KEY, so the blob is `MAX_NESTING_DEPTH` levels of `Map` down. - let mut cursor = &decoded; - for _ in 0..MAX_NESTING_DEPTH { - match cursor { - Value::Map(pairs) if pairs.len() == 1 => cursor = &pairs[0].0, - other => panic!("expected a 1-entry map at this nesting level, got {other:?}"), - } - } - match cursor { - Value::Bytes(b) => assert_eq!(b.len(), blob_len), - other => panic!("expected the innermost key to be Bytes, got {other:?}"), - } - assert!( - elapsed < std::time::Duration::from_secs(5), - "decoding a {MAX_NESTING_DEPTH}-deep map-key chain around a {blob_len}-byte blob \ - took {elapsed:?} -- looks like key canonicalization regressed to re-deriving a \ - key's bytes at every ancestor level instead of reusing decode_one's own" - ); - } - #[test] fn get_finds_a_field_by_text_key() { let map = Value::Map(vec![(Value::text("a"), Value::Int(1))]); diff --git a/tests/cbor_decoding_rule.rs b/tests/cbor_decoding_rule.rs new file mode 100644 index 0000000..42e6c54 --- /dev/null +++ b/tests/cbor_decoding_rule.rs @@ -0,0 +1,361 @@ +//! macula 12's decoding rule, which every stack applies to what a peer sends: +//! the shared vectors (tests/vectors/cbor/decoding_rule_v1.json), and the +//! verdict macula's reference decoder, macula_record_cbor:decode_strict/1, +//! gives each input with its reason, as macula-go pins them, so an input is +//! refused here for the same reason as in every other stack. + +use macula_rust::cbor::{decode, DecodeError, MAX_ELEMENTS, MAX_NESTING_DEPTH}; + +fn nested(count: usize, innermost: &str) -> String { + "81".repeat(count) + innermost +} + +fn refusal(name: &str) -> DecodeError { + match name { + "trailing_bytes" => DecodeError::TrailingBytes, + "bad_key" => DecodeError::BadKey, + "duplicate_key" => DecodeError::DuplicateKey, + "invalid_text" => DecodeError::InvalidText, + "too_deep" => DecodeError::NestingTooDeep, + "integer_out_of_range" => DecodeError::IntegerOutOfRange, + "too_many_elements" => DecodeError::TooManyElements, + "malformed" => DecodeError::Malformed, + other => panic!("no refusal for the reference's reason {other}"), + } +} + +#[test] +fn the_limits_are_macula_s() { + assert_eq!(MAX_NESTING_DEPTH, 64); + assert_eq!(MAX_ELEMENTS, 131_072); +} + +#[test] +fn the_shared_vectors_decode_as_every_stack_decodes_them() { + let text = std::fs::read_to_string("tests/vectors/cbor/decoding_rule_v1.json").unwrap(); + let vectors: serde_json::Value = serde_json::from_str(&text).unwrap(); + let mut checked = 0; + for entry in vectors["entries"].as_array().unwrap() { + // The CALL-field entries are the request's own field rules, checked + // where a request is read. + if entry.get("via").is_some() { + continue; + } + let name = entry["name"].as_str().unwrap(); + let bytes = hex::decode(entry["cbor"].as_str().unwrap()).unwrap(); + let result = decode(&bytes); + match entry["expect"].as_str().unwrap() { + "accept" => assert!( + result.is_ok(), + "{name}: {result:?}, but every stack accepts it" + ), + "refuse" => assert!( + result.is_err(), + "{name}: accepted, but every stack refuses it" + ), + other => panic!("{name}: unknown expectation {other}"), + } + checked += 1; + } + assert_eq!(checked, 54); +} + +#[test] +fn every_input_is_refused_for_the_reference_decoder_s_reason() { + let cases: Vec<(&str, String, &str)> = vec![ + ( + "a duplicate key at the top level", + "a2616101616102".into(), + "duplicate_key", + ), + ( + "a duplicate key in a nested map", + "a16162a2616101616102".into(), + "duplicate_key", + ), + ( + "a duplicate key in a map inside an array", + "81a2616101616102".into(), + "duplicate_key", + ), + ( + "bytes after the top-level item", + "a161610100".into(), + "trailing_bytes", + ), + ( + "bytes after a nested item", + "810000".into(), + "trailing_bytes", + ), + ("truncated input", "a26161".into(), "malformed"), + ("empty input", "".into(), "malformed"), + ( + "invalid UTF-8 in a text value", + "a1616161ff".into(), + "invalid_text", + ), + ( + "invalid UTF-8 in a text key", + "a161ff01".into(), + "invalid_text", + ), + ("valid multibyte text", "a1616b65636166c3a9".into(), ""), + ( + "a UTF-16 surrogate in text", + "63eda080".into(), + "invalid_text", + ), + ("an overlong NUL in text", "62c080".into(), "invalid_text"), + ( + "a code point above U+10FFFF", + "64f4908080".into(), + "invalid_text", + ), + ("the noncharacter U+FFFE", "63efbfbe".into(), ""), + ("a byte string key", "a1416101".into(), "bad_key"), + ("a float key", "a1f93ff001".into(), "bad_key"), + ("a half float key", "a1f93c0001".into(), "bad_key"), + ("an array key", "a18001".into(), "bad_key"), + ("a map key", "a1a001".into(), "bad_key"), + ("a null key", "a1f601".into(), "bad_key"), + ("integer keys 1 and -1", "a201022003".into(), ""), + ("integer keys -1 and 0", "a220010002".into(), ""), + ( + "a duplicate integer key", + "a201020103".into(), + "duplicate_key", + ), + ( + "the empty text key twice", + "a260016002".into(), + "duplicate_key", + ), + ( + "a text key in two widths", + "a261610178016102".into(), + "duplicate_key", + ), + ( + "a text key in one-byte and two-byte widths", + "a278016101790001616102".into(), + "duplicate_key", + ), + ( + "an unsigned key in two widths", + "a20101180102".into(), + "duplicate_key", + ), + ( + "a negative key in two widths", + "a22001380002".into(), + "duplicate_key", + ), + ( + "a duplicate key whose second value is invalid text", + "a2616101616161ff".into(), + "invalid_text", + ), + ( + "a byte string key whose value nests 65 containers", + format!("a14161{}", nested(65, "00")), + "too_deep", + ), + ("a half float value", "81f93e00".into(), ""), + ("negative zero, half width", "f98000".into(), ""), + ("the smallest half subnormal", "f90001".into(), ""), + ("64 nested arrays", nested(64, "00"), ""), + ("65 nested arrays", nested(65, "00"), "too_deep"), + ( + "64 containers, the innermost an empty array", + nested(63, "80"), + "", + ), + ( + "65 containers, the innermost an empty array", + nested(64, "80"), + "too_deep", + ), + ( + "64 containers, the innermost an empty map", + nested(63, "a0"), + "", + ), + ( + "65 containers, the innermost an empty map", + nested(64, "a0"), + "too_deep", + ), + ( + "a map whose value is 63 arrays deep", + format!("a16161{}", nested(63, "00")), + "", + ), + ( + "a map whose value is 64 arrays deep", + format!("a16161{}", nested(64, "00")), + "too_deep", + ), + ( + "the smallest integer, -2^63", + "3b7fffffffffffffff".into(), + "", + ), + ( + "an integer below -2^63", + "3b8000000000000000".into(), + "integer_out_of_range", + ), + ( + "the smallest CBOR integer, -2^64", + "3bffffffffffffffff".into(), + "integer_out_of_range", + ), + ( + "the largest integer, 2^63-1", + "1b7fffffffffffffff".into(), + "", + ), + ( + "an integer above 2^63-1", + "1b8000000000000000".into(), + "integer_out_of_range", + ), + ( + "the largest CBOR integer, 2^64-1", + "1bffffffffffffffff".into(), + "integer_out_of_range", + ), + ( + "positive infinity, half width", + "f97c00".into(), + "malformed", + ), + ( + "negative infinity, half width", + "f9fc00".into(), + "malformed", + ), + ("NaN, half width", "f97e00".into(), "malformed"), + ( + "positive infinity, single width", + "fa7f800000".into(), + "malformed", + ), + ( + "negative infinity, single width", + "faff800000".into(), + "malformed", + ), + ("NaN, single width", "fa7fc00000".into(), "malformed"), + ( + "positive infinity, double width", + "fb7ff0000000000000".into(), + "malformed", + ), + ( + "negative infinity, double width", + "fbfff0000000000000".into(), + "malformed", + ), + ( + "NaN, double width", + "fb7ff8000000000000".into(), + "malformed", + ), + ("an indefinite byte string", "5f4161ff".into(), "malformed"), + ("an indefinite array", "9f01ff".into(), "malformed"), + ("an indefinite map", "bf616101ff".into(), "malformed"), + ("a lone break byte", "ff".into(), "malformed"), + ("additional info 28", "1c".into(), "malformed"), + ("additional info 29", "1d".into(), "malformed"), + ("additional info 30", "1e".into(), "malformed"), + ( + "a byte string longer than the input", + "4200".into(), + "malformed", + ), + ( + "a text length of 2^64-1", + "7bffffffffffffffff".into(), + "malformed", + ), + ( + "a map claiming 2^64-1 entries", + "bbffffffffffffffff".into(), + "malformed", + ), + ( + "an array claiming 2^32-1 items", + "9affffffff".into(), + "malformed", + ), + ( + "a byte string length in eight bytes", + "5b000000000000000100".into(), + "", + ), + ("tag 0 on text", "c060".into(), "malformed"), + ("tag 1", "c11a00000001".into(), "malformed"), + ("tag 2, a bignum", "c24101".into(), "malformed"), + ("simple value 0", "e0".into(), "malformed"), + ("simple value 19", "f3".into(), "malformed"), + ("the simple value false", "f4".into(), "malformed"), + ("the simple value true", "f5".into(), "malformed"), + ("the simple value undefined", "f7".into(), "malformed"), + ("simple value 24 in two bytes", "f818".into(), "malformed"), + ("simple value 32", "f820".into(), "malformed"), + ("null", "f6".into(), ""), + ]; + for (name, hex_input, reason) in cases { + let result = decode(&hex::decode(&hex_input).unwrap()); + if reason.is_empty() { + assert!( + result.is_ok(), + "{name}: {result:?}, but the reference accepts it" + ); + } else { + assert_eq!( + result, + Err(refusal(reason)), + "{name}: the reference refuses it as {reason}" + ); + } + } +} + +/// An array header for `count` items followed by `present` zeros. +fn zeros_array(count: u32, present: usize) -> Vec { + let mut out = vec![0x9a]; + out.extend_from_slice(&count.to_be_bytes()); + out.extend(std::iter::repeat_n(0u8, present)); + out +} + +/// A map header for `count` entries of distinct unsigned keys, each with a +/// zero value. +fn map_of_entries(count: u32) -> Vec { + let mut out = vec![0xba]; + out.extend_from_slice(&count.to_be_bytes()); + for i in 0..count { + out.push(0x1a); + out.extend_from_slice(&i.to_be_bytes()); + out.push(0x00); + } + out +} + +#[test] +fn items_count_against_macula_s_element_budget() { + // The array itself is one item, so 131,071 zeros fill the budget. + assert!(decode(&zeros_array(131_071, 131_071)).is_ok()); + assert_eq!( + decode(&zeros_array(131_072, 131_072)), + Err(DecodeError::TooManyElements) + ); + // One array item and 65,536 map entries of two items each: the item past + // the budget is a key. + let mut past = vec![0x81]; + past.extend(map_of_entries(65_536)); + assert_eq!(decode(&past), Err(DecodeError::TooManyElements)); +} diff --git a/tests/vectors/README.md b/tests/vectors/README.md new file mode 100644 index 0000000..f8ffcfe --- /dev/null +++ b/tests/vectors/README.md @@ -0,0 +1,16 @@ +# Interop vectors + +Every macula 12 stack checks itself against the same bytes. These are copied +unchanged from macula-go v0.12.0's `testdata/` directories, which carry +macula's own: most were generated by macula v12.1.0 on OTP 28 (each file names +its generator), and `identity/lamps_mldsa87_rsa4096_pss_sha512/` holds the LAMPS +draft's vector for id-MLDSA87-RSA4096-PSS-SHA512 as macula v12.7.0 carries it. + +| Directory | What it pins | +|-----------|--------------| +| `cbor/` | the decoding rule every stack applies to what a peer sends | +| `identity/` | TLS and CONNECT bindings, status statements, the LAMPS composite | +| `handshake/` | the version-4 handshake | +| `frame/` | signed neighbour frames | +| `record/` | own-namespace procedure advertisements and their verdicts | +| `manifest/` | content manifests and their ids | diff --git a/tests/vectors/cbor/decoding_rule_v1.json b/tests/vectors/cbor/decoding_rule_v1.json new file mode 100644 index 0000000..f0c53b0 --- /dev/null +++ b/tests/vectors/cbor/decoding_rule_v1.json @@ -0,0 +1,310 @@ +{ + "version": 1, + "rule": "DESIGN_PQ_SIGNED_FRAMES_AND_RECORDS.md, Encoding, Decoding rule", + "entries": [ + { + "name": "an empty map", + "cbor": "a0", + "expect": "accept" + }, + { + "name": "a map with a text key", + "cbor": "a1616101", + "expect": "accept" + }, + { + "name": "integer map keys", + "cbor": "a201022003", + "expect": "accept" + }, + { + "name": "map keys in any order", + "cbor": "a2616201616102", + "expect": "accept" + }, + { + "name": "a text key whose length uses a longer width", + "cbor": "a178016101", + "expect": "accept" + }, + { + "name": "an integer in a longer width", + "cbor": "1801", + "expect": "accept" + }, + { + "name": "a byte string value", + "cbor": "a161614100", + "expect": "accept" + }, + { + "name": "a half float value", + "cbor": "81f93e00", + "expect": "accept" + }, + { + "name": "a single float value", + "cbor": "81fa3fc00000", + "expect": "accept" + }, + { + "name": "a double float value", + "cbor": "81fb3ff8000000000000", + "expect": "accept" + }, + { + "name": "null", + "cbor": "f6", + "expect": "accept" + }, + { + "name": "valid multibyte text", + "cbor": "a1616b65636166c3a9", + "expect": "accept" + }, + { + "name": "the smallest integer, -2^63", + "cbor": "3b7fffffffffffffff", + "expect": "accept" + }, + { + "name": "the largest integer, 2^63-1", + "cbor": "1b7fffffffffffffff", + "expect": "accept" + }, + { + "name": "64 nested containers", + "cbor": "8181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818100", + "expect": "accept" + }, + { + "name": "bytes after the top-level item", + "cbor": "a161610100", + "expect": "refuse" + }, + { + "name": "truncated input", + "cbor": "a26161", + "expect": "refuse" + }, + { + "name": "empty input", + "cbor": "", + "expect": "refuse" + }, + { + "name": "an indefinite byte string", + "cbor": "5f4161ff", + "expect": "refuse" + }, + { + "name": "an indefinite text string", + "cbor": "7f6161ff", + "expect": "refuse" + }, + { + "name": "an indefinite array", + "cbor": "9f01ff", + "expect": "refuse" + }, + { + "name": "an indefinite map", + "cbor": "bf616101ff", + "expect": "refuse" + }, + { + "name": "tag 1", + "cbor": "c11a00000001", + "expect": "refuse" + }, + { + "name": "tag 2, a bignum", + "cbor": "c24101", + "expect": "refuse" + }, + { + "name": "the simple value false", + "cbor": "f4", + "expect": "refuse" + }, + { + "name": "the simple value true", + "cbor": "f5", + "expect": "refuse" + }, + { + "name": "the simple value undefined", + "cbor": "f7", + "expect": "refuse" + }, + { + "name": "simple value 32", + "cbor": "f820", + "expect": "refuse" + }, + { + "name": "invalid UTF-8 in a text value", + "cbor": "a1616161ff", + "expect": "refuse" + }, + { + "name": "invalid UTF-8 in a text key", + "cbor": "a161ff01", + "expect": "refuse" + }, + { + "name": "a byte string key", + "cbor": "a1416101", + "expect": "refuse" + }, + { + "name": "integer key 1 beside float key 1.0", + "cbor": "a20101f93c0002", + "expect": "refuse" + }, + { + "name": "an array key", + "cbor": "a18001", + "expect": "refuse" + }, + { + "name": "a map key", + "cbor": "a1a001", + "expect": "refuse" + }, + { + "name": "a null key", + "cbor": "a1f601", + "expect": "refuse" + }, + { + "name": "a duplicate text key", + "cbor": "a2616101616102", + "expect": "refuse" + }, + { + "name": "a text key in two widths", + "cbor": "a261610178016102", + "expect": "refuse" + }, + { + "name": "a duplicate integer key", + "cbor": "a201020103", + "expect": "refuse" + }, + { + "name": "a duplicate key in a nested map", + "cbor": "a16162a2616101616102", + "expect": "refuse" + }, + { + "name": "a duplicate key in a map inside an array", + "cbor": "81a2616101616102", + "expect": "refuse" + }, + { + "name": "65 nested containers", + "cbor": "818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818100", + "expect": "refuse" + }, + { + "name": "an integer below -2^63", + "cbor": "3b8000000000000000", + "expect": "refuse" + }, + { + "name": "the smallest CBOR integer, -2^64", + "cbor": "3bffffffffffffffff", + "expect": "refuse" + }, + { + "name": "an integer above 2^63-1", + "cbor": "1b8000000000000000", + "expect": "refuse" + }, + { + "name": "the largest CBOR integer, 2^64-1", + "cbor": "1bffffffffffffffff", + "expect": "refuse" + }, + { + "name": "positive infinity, half width", + "cbor": "f97c00", + "expect": "refuse" + }, + { + "name": "negative infinity, half width", + "cbor": "f9fc00", + "expect": "refuse" + }, + { + "name": "NaN, half width", + "cbor": "f97e00", + "expect": "refuse" + }, + { + "name": "positive infinity, single width", + "cbor": "fa7f800000", + "expect": "refuse" + }, + { + "name": "negative infinity, single width", + "cbor": "faff800000", + "expect": "refuse" + }, + { + "name": "NaN, single width", + "cbor": "fa7fc00000", + "expect": "refuse" + }, + { + "name": "positive infinity, double width", + "cbor": "fb7ff0000000000000", + "expect": "refuse" + }, + { + "name": "negative infinity, double width", + "cbor": "fbfff0000000000000", + "expect": "refuse" + }, + { + "name": "NaN, double width", + "cbor": "fb7ff8000000000000", + "expect": "refuse" + }, + { + "name": "a request naming two proofs", + "cbor": "a9657265616c6d582000000000000000000000000000000000000000000000000000000000000000036663616c6c6572582000000000000000000000000000000000000000000000000000000000000000016670726f6f6673824970726f6f662e6f6e654970726f6f662e74776f6674617267657458200000000000000000000000000000000000000000000000000000000000000004677061796c6f6164a068646561646c696e65016970726f6365647572656661636d652f706a6672616d655f747970656463616c6c6a726571756573745f69645000000000000000000000000000000002", + "expect": "accept", + "via": "request_fields" + }, + { + "name": "a request naming no proofs", + "cbor": "a8657265616c6d582000000000000000000000000000000000000000000000000000000000000000036663616c6c6572582000000000000000000000000000000000000000000000000000000000000000016674617267657458200000000000000000000000000000000000000000000000000000000000000004677061796c6f6164a068646561646c696e65016970726f6365647572656661636d652f706a6672616d655f747970656463616c6c6a726571756573745f69645000000000000000000000000000000002", + "expect": "accept", + "via": "request_fields" + }, + { + "name": "a request naming nine proofs, one over the bound", + "cbor": "a9657265616c6d582000000000000000000000000000000000000000000000000000000000000000036663616c6c6572582000000000000000000000000000000000000000000000000000000000000000016670726f6f6673894200014200024200034200044200054200064200074200084200096674617267657458200000000000000000000000000000000000000000000000000000000000000004677061796c6f6164a068646561646c696e65016970726f6365647572656661636d652f706a6672616d655f747970656463616c6c6a726571756573745f69645000000000000000000000000000000002", + "expect": "refuse", + "via": "request_fields" + }, + { + "name": "a request naming the same proof twice", + "cbor": "a9657265616c6d582000000000000000000000000000000000000000000000000000000000000000036663616c6c6572582000000000000000000000000000000000000000000000000000000000000000016670726f6f6673824473616d654473616d656674617267657458200000000000000000000000000000000000000000000000000000000000000004677061796c6f6164a068646561646c696e65016970726f6365647572656661636d652f706a6672616d655f747970656463616c6c6a726571756573745f69645000000000000000000000000000000002", + "expect": "refuse", + "via": "request_fields" + }, + { + "name": "a request whose proof is not a byte string", + "cbor": "a9657265616c6d582000000000000000000000000000000000000000000000000000000000000000036663616c6c6572582000000000000000000000000000000000000000000000000000000000000000016670726f6f6673824970726f6f662e6f6e65076674617267657458200000000000000000000000000000000000000000000000000000000000000004677061796c6f6164a068646561646c696e65016970726f6365647572656661636d652f706a6672616d655f747970656463616c6c6a726571756573745f69645000000000000000000000000000000002", + "expect": "refuse", + "via": "request_fields" + } + ], + "via": { + "record": "the bytes decode under the decoding rule (the default when an entry names no via)", + "request_fields": "the bytes are the fields of a CALL, read under the rules its field table gives them: at most 8 proofs, at most 256 KiB of them together, each a byte string, none repeated (D7, chain transport). The total-bytes bound has no vector here: one would be half a megabyte of hex, so each stack tests that bound itself." + } +} diff --git a/tests/vectors/frame/erlang_neighbour.json b/tests/vectors/frame/erlang_neighbour.json new file mode 100644 index 0000000..b482072 --- /dev/null +++ b/tests/vectors/frame/erlang_neighbour.json @@ -0,0 +1 @@ +{"entries":[{"connection":"3d634ec67d881a7824567ea8a2dae147fbad1c604eee868cddcae2a2e3a58291beb05bbe876efbb655f08d96df9bfda2","frames":[{"bytes":"0000154da36776657273696f6e02696e65696768626f7572a26374627358fdab63616c676f4d4c2d4453412d38372d50533338346373657100657265616c6df66763616c6c5f6964f6686672616d655f69645001a0d10e8559766b98b6fd9a9e1244246a636f6e6e656374696f6e58303d634ec67d881a7824567ea8a2dae147fbad1c604eee868cddcae2a2e3a58291beb05bbe876efbb655f08d96df9bfda26a6672616d655f74797065696164766572746973656a73656e745f61745f6d731b000001a0d10e85596c6361706162696c6974696573006c736f757263655f726f757465f66d6164766572746973656d656e74582761207369676e65642070726f6365647572655f6164766572746973656d656e74207265636f7264697369676e6174757265591413f3be773ad879c787299597d7b5e276f2892c6a47ff1439bed92632adbabd8cfe6ad0d3a02d378404fb5d0d89633f698ff73f2fd557870b7f680b5d86edddb3aa77a4ce576f34ae71c99aad170bb1a0006fdf190855a9fe9a6c34ab491cc466b6fbeaa1b875890e2c2ec6aad8c9ca675572cbd0c0153a17928f7373fe5897e8dbb33001238b75b19d1d6330dc4be05d9e52df293f6a040c496b5fff47966d60abdcc00161ddd71647d6a6a28bc9165d22f4fdc8a949833866b1813fca301b56a8f981ff7793d08e378b102867f058167e8fcd9b947a277b0877e71c2d1331088a89d3312357e4fe858bcb7c4e2fb8c8447514debd8df381dbe54d1a60d01a49228eb5d34546eb1a5f66b76aa889f21682ad5455a8299b19fe09d729b70301398b9dfa3e79df7cf6116dc6acebaac905f53160ce78808852b4aa76173e9fc84e1239284d93df7948e7e2e24bb6600a05b0cdafae7f6a959c3ae0618db0161481ddd374f0b57a08d4672ab0f51b6b144ddc52846804f1e0037bf677a1b11e8cc95d980a5a5706af9e7f0f445cced0ab1ce6a1b05cd6a22db2e25a02069dc7a68536bd067f2a1fdce5eafc1b5a33c046d1b86e87ddbdb67d11dbf9738304279639a06820ad83f02b3fe9974c85de30964d1c8c1c51d8d352130a70a87dec066e9bc7bd7a198a52c2a158d0b2243374b37edc8f598fbdc7c60642653689738b1c4562a2ae80e3d6c82c850de71b69abd23ea6637608da41413b96e2f554bc091841df5cee3799ab3b116d4cf0dad628de0a4eeb95022fbcc56de5073933f7a07bd7b1f6281ba28f84128f455f5cf0ac0c23c1fd590db606337cf1216ebc6d33d872d12d43020426d6237cbfce1fb8bbd73939e5608fd4a0816c145add1622eab8dc37265ddf97a9449ddf6a37b7960ef99137997ff60ef56ba2f7ce3e6073e662e9a59cee71a8c7b0d156f8b0d0dfc478832c51ef727eb0188f6ebcc6ca4f1502beceee457eb5e27e3424a72825b1e034a81c4b25b3efc7efbaad35585c8e8f8aeea15909f71959fdf102b35c4eb5e432a4f8e2adbd3515362213b7c4ce526fbeed5cb493997b96d4da38231cf8ed7c5207849bf0e593e224b68f6f4cd04558664031c66c2877739ed60b4c967b54ceec16fabb7902046dc296bb7137f70eb8385bb3007b9aa9820cdbea64ad27bd5f8c806cc8b7f9c18a441d9b16e7ab1765e7c23fe5d4fd77e496d7ca0432f7fe2f83bb7c80057f5adc3965ce46a88126b1075e9c5276857f3b867f44e6fe895da6361c26efa8a472d3d1445797ac0eb8afdf42772761cce55b5e22b53228e00c729ba7c513907c419b0c2f53c438bc37108140e49c01c4316643ac133f023e2aecb88e87810960e01526c3273bf57b2db3e7018b390ea2170fbb851985425235dcdc555fb264fb1d0c79271c018a1196064289cc1a9786143059793fd18b62c3e33ec0a6e63e545f76710775884092d017352165d79f1643ba8ae1d4e59805101a83ac3eefcbaa8aacbb14162db9babc06e32a3bfaa2e1e93723ad553d4041e2fb15060e6ea44723b67b30f493b3afb2cdf112aa20cb54a6e59a7ac803b70d9cd540b7da9d7019b13f0f4fae0536932263284a8f97c35ecece32ff343ecb96cb340be9033f07b73daafcd13bc947fa966cfcdbaf44bdbeeb835a51a32f4933ea557792bfa1ea76bc65665c0e1bf06d92e9be3913576cd6a863db84ba7d17574438c50d8f28675661b87fbb0cec90710d458d9e390d7e682446ff764ca6610fce31acd7d7bdad18722bbb394f1672218801c22778fc3bca3a08bbf9f1b266337948a65d49c42ae263f9f7785e001f1fc865e2e70536cc0c58de5ace962c358907c2f9c192a939495c4d624a935f8a4eb8f52a9ebe128a983d93a71f72c80204427afd1130f088d556e9871804efdb5e2f49e786bc48bab730ff83327b474b9046f38209dc5f658b5ad94cfe4e5e0bd3792da900c20df91b572885d39a122fc946cd39b33316f2504b5513a93186cac978887984baaeb3ba6581017f509207803d413ef02744ec3aff12f19564c474359ca1c06e39e3826331e33a302885b3d9df37f7eae1037096f42e37c3e547c8d8a665cde42a9a9755a9cbe5df04a22f27480d834b2780fcad8cc643182ab84d221997c6b75c22c9306053b24c463935b52e631fdbc3738fa95d7d84323716f6e35237eb523f3dcb18d68bb921d00b72c3d879409d31ad1ba4239c6e9deb0fd51c325dbd5aeb29a2fef42cd1b2ec4dd9717a21959cc30dc48f73be8cd6071dab7cf6d92f0c6da0ae034942dc68098e1a2faabd7ef1b7e8cf497dd16292a74ea96fa3609411ab5bc6b5f729cbc6964e543ad069a79343ee70ff637305afe7d9f4781f6d46be6f1aee86918e03da6477b7a2f4a714bf2ec2337be3cb7c614795cf61dd8f55b0546b9934ac6fa9a3dd4c0ae042a87267585eadf9ab6b986d93745c4e8547ab12e7b9ac9659bb27195183b617599547c25a4d2130fcbfae4a5e5acacaa124c5efb972af61276595e2cbfe0c99d9f5cdab8bbf69551dec855710f5b082d6ee46259c364b23411283b955087dc2baae05bec312a843bae1af41176f8f20ddc60d36d5e4a4ae793f73cfad0b83c2aa0dd2dfc116fd7f590e841a80f9fedd9427b79b55b8d027bf9453d3fb3760862fa119ee8d42c2b544a6f2603f039f272fd1b83a47cb364f317f13f079c1a773cc0fb56270866dd7152d42df4c4287ccf424164ed92b08ba39e94d6a14efd0d8feda9adfcf5577c864d57dcdfac55c026e5519f823712e15fb21547d9e9ddbd409935985077d0e562911ca56b994086a1cb20d7318989faf3dbbd3388db7667d92fd1b4036eaf6f4fde208fdb5434fe3b5e4bb372f02964a487906150c130f5e357616c6c2a9ddbd0e25c06792d91b5d1e052705343a338b2fe419e3badd93ff1d5981c747d34c9561f80bf83ebd322b608402a07a2209406fa94b3b9e90c6f091fd65f2db911be9342d5bba7cbb4623c0d4041ac8b066dd53c719c876e20876c232b4db1f35aabd98252045828e649d0e5f346f48fc3a9dffd961f994d2bde1044744f2be02b155e3096c0162f1c9c85c70dbf8a7d47947c6485e1d13a73164e89099468942cfd911232cc97e2a1333f0ab18a20f2e904db38c6276c75c7180596491a92cc20b0d82d5bba843bf001ae6d087734a93616f16fd4fd9e8c4d9bf780531b338b6e98375827391af5c08b7ed8b6bdcdbb5df60d344194b3adb9cad1fced5205d9fcbbc77223730bf2da560ae1310a3cb7f374db5f8db2c0f767f26320f365778516e3f6b13e9f7629cd0a996934d190f372c8f27c8014903174fdb0e2e843e6790168d3c4a061ee4888db0ccefa65541ffbc4a5798a38f5653f64fcc56a7431a7714a08bab66e1df2c38050de78533d15c412dfb20ca74156bf7bdf00ff637ee52a95236c2da059cf4514f5cee0051ec379ac024ed41c86d1f973aea3edd4394596e7875266b4cb2978c920500cda22522433d8297cb03198327a698e9e0180957a345966c7f0d7c996c2f1589c7e93a68cb8bf36fc31bc3d41440b575251ca57f76451380236b32e55313521eddb6ad851a5a8a1519d81181c6e81a6c247f649fcef26d84d8fdea65e5fcb3fa1dd2dd434d7ef2884fcda2f9921bd8905d22613fa9955890b0db18fb66f181f3f7295eab547b143c311075258006365d255932f6d8118514b977905dd435ef653a0352aae5897e49e2d521bb941ff1b01782ab035256b422395d4314777576bef9f48c5ea6f365c1a2199f88767d7e9072dec9a02b805c0a6fc828143b76f31abbd3b66114df4b3a059a265ef344649250a2062d68f8994455d87a92403094c038ac60cf6871c2b7ae6d9fb732a34e13396eec64020f7e8d0ca19060de6ee1d32965e4bb4bee7d16a715191aeccd8ddfe668c4b927c17969535c383c5fff5f7018bbf9381f6b928a2d6468217f9d93696a0b4b692a1ebc374ddb8310ed63e1f8659f40ce4193bad422a68b41291d56093665b1542dd462fafc00fa35987012d686493245658d90e5bac6845690cb30504d452ba0d83179cc937cb2f5673f30e12e25d2b328bb9391f4090d38a73dd21da6f08c9289e9eb34f453d6abb3517246e26c48d98b01c7b78015ba359b91ed792bec9fc4c71d773a496b69fa34176b60c8d3bd86458e87bf65191d44ed73386993154921794d20127ba106333e5743cda2afed9fd9d1edaea81e9532302a1187e319f39724598df72fe431cd110aa397ab447d4f7a696884fc932613cf4dd117800958196c8c62b6e697de8daf6ef0527b90efbaccf92cfeeb29b6eb7d4ceeb1e4c27c66a353faf83bb1becbf1df7e49f47e61f005d7aae1f09a755b69ec53f31cee082e9e579bd7ff2c74e69d12be3327d6bb37aa8fb1f3157faee8c06e7c7ddec4cab5042f338b482bccf6511b5f5ac733b090c47a687e72277aab239597d342673f38f00ec8a3cf7687329976961b87cb938ee28e8f0b16b9988a142fb9fcb20c6e5fccd19026643a4e88932ae0a8bfc30d520603c3ed3065167f606d8bcd3643233a0435e822b4c91963611c9b60ae61616b08918dce11653594bf1836e16345a2bc7a6d2900f43f894582927efdb4d4b97158cc0888037b1a739f94bad77c8c45cd0578d4f4eb52f00f4f1fcedcc0e162f3632673bb330fc8c1d0ae3c2ac34e761300050adcbc8d15fce667ae3db8bf7eb995ac0c5c8e0e0354476eb3846d73ac5b4039b68740a6823fab21da97da24772c457269d0dca61387c674c645f66a971fdfadc686095f0cdc88f80b0a363b7d35f0cf2f848379e5ba73352b5148cfdd2b4b5c0c728b6b38843a3db918d3830ebe39d4a162b6523bd51609982e15f3eb7962570a63f401edde57900824cc8b4bf88253c3336ef3cbcc8aac7ff09adf9524838e57ba3f0417b85d0ba063b33c688f01e934e1dd8c8ed21224886da20459ec122f09b91fe7b373c446305183ac634866900c016b32eb7988cd2d9301c95777b8d99acf3076410346e0011b1d675eb6b71cd89e65c07c8601ed7bfaf41a556fb9b12ecc715201597da7e370f7b88e1ca50c658ff281ba5f556ccd80e020721710a249b94be92e3e1fa7c0bcbe1f1e805d932a99ddbc459f198f483e5617ebdfbef2661760cb58f157be1c1a446ef3cc922cadb9cb866e88b8f232eecc2a4f0a123c0033e10e7664f6f1b072d42748466606018d07a77c5e9bca4f106be7d26a3d42e8aa197dd4f26d336866b1a0779d1f694972076e072916939be3627833cffbe159f12d95b24230eaa4b7f28a494c0d740a2b6f815d701bd06ba18e5cceeb3a244cbeca5f1ed87c1ad05119257210db7dc13ae14f209e86624f2e0d7c44d50eddf7d8db5609e23eafa9ce095b1cb6d368e63a482808dbd5484c44f74be035718dc6b23bfe17adee933c08e0d9f2893d372ff9d8abf60110c7d44ff008e0a4c75fbf26f56c6bf984312b1c1d41d4bf36eca2a912384b2af861e89b9b86a2fbe793553dda0a7bde2da6831cc4af4895218b5c23b41adbbb32ec565ac5d425f5ac9208efc11661ecce5369b815aabf3265f9625e9b7193e5195afe25a1836b49a9f80d4be36290b6dc021ea0be60a53a731207a12d9e52fa2c47a97077d0d47df3ca852d60d01c55e8f0ca2d9937eb1304213cb32814b5b7dcdb8c1622849898956c6d4a15d89aeebad7d2ba7b7de30b2a00667fba4067e75678758392adb95f0271c6968ca43c05a555edc6d0c111ed38db49d4add0e07319bf59b7495a19478d45436fa7cc8302dd846d9e834ec081741c4a202f8217dd2deca4381499963ad2e332cb15f678c58fdb39a1457117d718f3571ea89b170ca68b11bc2fd0292521c81eb791420674002ffa491d39480eab8db34e222c361101af130e3d34b046ec9b3017eb5c0b71fbd56eace6ae84f55612b372b202c8f2ac3d1225ec783f5c80a0efdc702e95bcb68c7c1dc825c82d452f5e353baefb9388063516bbbfea68e4e30e098967a292cefcd03808c786f1510837ee21213687cf0f63b39cc2b23c70e18c938cfd01b23d0e66c221227eb299bcf7a359ca4c83ee16fec133807b94f61cb4dc2cadf585043e427004e9ac281521a16a84a0025a13f3017e71e2864833f4126e3a2306b8666c34b644e7108b9e0245fd284de72f0aec436e77ab7b6ba274de80d8f93f2570ea21fdd0f78143ca866f29f598d7704fd80c31a3831ffa2b59d61c75847afa51a3b72f73695070188320eb15865eb64163b33a6434e8f9c4599a04a3df30fd1b5b9e529c1c5f8dfc17139462346b7215a25a0be8917145a2f789d8a7dd62dcc2deca4bdd6fc351e2288597a2dd4e4383983c05acf79c1314018bb0e4748ed8d41f482a4520c303101154566694abaeb0f7102a68aabddcdee3e6fb235f6a90a9b007237b8b072528587cb4cbd6f401ca0e18dd1423a3b4b9e3ee00000000000000000000000000000000000000000000000b151b1f282a2d34022d5902b2c369de3265aeb7194cc49a951b5a045cf5bbee07a524ead3967e0b5debe5c61eb01c6c6f9d4d9d87417921ce5c642df91bef30ab801a92edca053392b36ca2fb84d0f319ef61bda945d10c7923808a2ac00211d9b555f66325a5de842124515b489f1927a709e9afcfb88e0dad96974562641061510e4a2d8625ef9b7370412bc6b36aac0ca272ff0d63e2055cc31c1eabde4342ace28805a1ef5ce0931d64982411dc56a9ca405e35f52d32d1f9c8d24135dededc6aa8f8432f269793f26476df69b14ad026e78af6859f06895875ae58fb91ff6dbaad834e4fca7a0c4e22772e9ee5fdfb9b3135cddfb6a72b75df21f4c2bdb9c62bf5258e47f13be86f5219c9b776b22e8fcbcb1be8a37cc03aad8ff771ff4daf7afecf11e9ee0f2cfadc0de523dcdcb6ed2437616b64297e0906305ec54f0e32cff646ca0f9b6bb63894b00b081ed90d09af92c9bd6ab4cb32cf4bb8053ad7fe73be061aad333402a00a1988cfe55b43fe547fa68f5350b93eb7878d8ae5cc779812e41d181c612b7e630181ca1e22308430605303c7a297a01e0fce1e71e5c310f2a35b50eb1022a4ee08b6c1a8b5986f341d06b8fff281816a298186823be8de0f21b8a46eb35e7e1d48347ed25197bb23950ef1e867d4eb2eccae83c5953e05f399162387c51575facc530e998a1f9d083d50ef12db1698f1036fc2ddc7c56e66d0d9da676a6672616d655f7479706569616476657274697365","frame_type":"advertise","seq":0},{"bytes":"00001541a36776657273696f6e02696e65696768626f7572a26374627358efab63616c676f4d4c2d4453412d38372d50533338346373657101657265616c6df66763616c6c5f6964f6686672616d655f69645001a0d10e855978dfbf20dd2a708a72b96a636f6e6e656374696f6e58303d634ec67d881a7824567ea8a2dae147fbad1c604eee868cddcae2a2e3a58291beb05bbe876efbb655f08d96df9bfda26a6672616d655f747970656b756e6164766572746973656a73656e745f61745f6d731b000001a0d10e85596a7769746864726177616c581a61207369676e6564207769746864726177616c207265636f72646c6361706162696c6974696573006c736f757263655f726f757465f6697369676e61747572655914136c58dae08e778ae71c7c9ce1f6a392c643f197bd689796ddb60dd6c2e83635a40b2e919825044452b212f1eaae83097d7f9a4899b20ad56b91aeff632bc58287203bec9dca9b1363b5978d9097c2137b9a3d3b6bc7801fe582b1c1e6740502f818ade7483ae4c7a9274a4803239f67e17f2102f742b65cb015b22404b5b04e6d5cc99dd350cf3acafe5ad66c8f413fddbd21e59241c19ff5a1cac21441376296ef131da161e59d79b1812be7b09944fd49d513fd72e9da0dd79aa2026f67d56d5efa31fd3c0e1b098556c938d8159ea21ca03227b3f78f1de1c4625ef366c6f506a294693f078fd7f6b13066f2d04126c797837b67031cf73ea969d317fdb757498ae93a76bb484ae9b95b84ab1a37b1a1b0bb77e05a7275bb2c65502b7f1b3f0101146a74fc0b555dda8712ae10b0f1964fe03726c694a9fe60f8b368ae59ce63fe5f3100b6e690727b97c87ea2d52ceb7eb5a252f678be7e903ed8106cae02e98b61ec5c2681212cb22d59b3992f4d5b6503ff5a99c06e4415623b5445695af17bee0575e204e3a2f87ea000104f41ee0ffc9b2308ee44e1c47e0d57feb94613f6745cc859f964727f87e3a7264f4461ba1b515bd3c41d099442e6037773dcafd0c4a0522021f32d917aa45143a11b23a635dd310e34d604862403502ebe4df536f3fcc62d50bb30cf520c83671bb506aa53b87b5e4ae47bda2197e0f1614b3b352ff5481f6c369ad56182b6a55b6bd3a81dd51121e516661d1b4e7a34ce8aecea2c6e246a1eb3905cf672370bbf28dc5ef942fdefe035444bd8f9bfc98eaf50b99103e9606c6b884c97b1a3e2efa5c1908d0e3ed0c30d689ad66cbca5206704cd66f632a4ed6114a10e142408bb117054428b38c2364978d1bf7074a7c49f78e53014a51ba306dc2d6c893636378d21767e43d67bc5a83db88dfa8767f040fcf2bc6a0cf00d899c48e7f8e1c1fe4a25df4e09d3ee368fd8970785758ec4b4a24852f4392f8d63525326bfd453d40b860d3aee068debeba821fd615ac3738a60ac8a67c5b69eb44784cc6411c4b272edcba588f942d899cfc827a17209eb47b8c58479594e767d55916487f68b42395df279ab8bd1b4a7d2bb2bf2b96fc2283fb5b3e20131902b22bfcfe68822161c11157abd18faefc0e86edff9140a1d7667794fe80881d13ea71e86b3b77a23db3cbe463a8a1cc93c62311fc65854d6f415ef9047af28d8948d08803f796835a20ba92d44faffe0971dcff9c90efcfee1f4461f7bc6708e072bd836e8540fa9d034b57cebb4b6aefe39b986aa6ba7bfbadcba54f87a8b7de1a069fee49ca11bfb16915c8c0ab71129a3e8438e0e53f7d7bee1159cd7070f322cf1e937f3d91c75fd6a7901684384664fbf59e0ef0e13e9eb4dac9dc0c26d22b89ba1f7839d28c6320e5c92b967c7bdd3f7217530f252357fd5a3b4aeac758e73378119f71e71e1c6c1eb602a34ab391faef97cea45b48b8c829523fcc4758b98e8cec06d75416fc0eb7feaf7fcbd07fc127af0daf0a79ad80b0c8eed91d3158e91e4721f50505938ea6710fe0f66d8524f9cd5d3aea76f6da215a6b1f374257a96637b3d329b83b5e70807c564ee2e878cf07f463c0147134cef393deb4fce88187d8eac75bae35bb3e92170bf4aac3f40c0dc1a18e74cf41cf8ef5a4bad31d38111c37625ab9d941011bd7553659852bcdd728d09b7614f47d1f1179f7025a6f4abe12dfe09a949d6d88ae2ae8751c3572b393f559afc5e715fb8e0619db4aed66756d3560b81ddfb9b6e98dbb5436cafffdc5e6da66c7f35f553455282674dcf1192cac19da86294d52a4aac4818eba80c45429c084c88164c05a817fae714b5f9ebf44279ab3297380559ca76fe20efc7242777205099fed7c0be1e714db15ab60ccef8c3707ad9f8576715175cb200a8df65ba42ad2560f79475c3058bf3885a702f89389223e1f37e3d0ea08536416e10aeee941209b4162cf2845d02d597d9a71366a029f8802a13010f01d183b72a98bbe5c0514dd1aa097aa940a05a866782eec2f71242cfa116dd846199a0508434bf424234c5131b0d8d455bc258d1cb12677146748c64524b00259b53c74aff7e5334104de75b5aa51054842974786993907ad3638473f7460739508bcb1c79c48f0beaae24e8f7c828ef56140888d4bd4aa0957c1d58635b6e51d0cb9d56587dff420908dd1b6ebd461be81878498944a9a2f80e585585e8b1dcfb6f9bd975d2327780189ae0dcaa59ae752ab264b92e3099ea2304a4aea21b9b60ba4ef0e534eb8d4edd0d5e4ae621a493da2a9bfa6c8b3744b3372ce71fc002227815993667a5b8d78b7a1ae90026c188ee503be4b1de15bb5bf3b55028ac6e8719c8f53852cb09f9ceed1c9bfaf74d6710123bf4c605d4db9b6ebc14bcd7e7ff121c4bbba053f1ac2593ad774266ecd19d0af07d49a9f31e0b08d3464ee80ad6fe20e2c952a1553efbd757a2913ffdce2436c077ddfbf87d75ff35021489743ba67e57317a694ba8937d3a1bcf0b84377c78ba1a9c5f6c362604c5c4a3d1ced8b03a62f424abd3d87e6d7e7adadabb45f913546ee80c3165d0895e8c4ff0b710526263e70a3aa046adbea079e4dac7da0c3e1a9c700fccf5ee7443159108b0ade0d551b5e90d93fbc84e818b0246b1974d5f103a46dc1223297d71a1a78e5a7676d555c8ce32a155e10a04be6edcf745e163d776588b22125c3d495d368502710394bccf1d017d2f28625d93ce46520c853824fe0f293f44aea894e7dd637d8ce4e52e6cbc38cf160273999c2e118cd74cd9e9bc7f1456ba87b228619e0267a34cd5c72a0cf873e4d257616aa69bf666bba1cff26dd9fadb754bdee0fa3dadfcaf15cee55f9655c167b1dbe09914ee929505b564b9ea26cd3a73e72e378bba0aecaa1ede4e2c84790740615279a8b66af2a145b7dbe26526979c397d08031f5e8b0963e3fdbf35bab7904b07d48d8cfeb2fe39252e5de1f477f0bafe20c99ee027f03fe6d8dd80996bd8156f60f9919752bd062bd06bd907247825c826bfdac148b5b5adcb5be715b790a6a6532bd0c06b78cf8cc2e469e2da058d513438c137e409e71a0d44c66cebfb6032a9f6a9779f057167141ac3e47a6851aff424d9dd9abe566abb8d37403ca52e0acb71cac9b908d2b5d36a82db9d365ce20919255123a0d865da8f158a70186c46faadbb9370e96413807c7b570fd0a9334486cf0b009ee0115d3bffb6c1c76342d808dd2df284be7da3b89616a4fcfadc8d2ba1e9b6c46737a5af9b01ae468bfe067b6e97a2d0e49466577590f05cf05e4eee5f80512a4ec3b365e76da856c3c2c47e5e8f08933178757f83a3f48ea5e26ef92808300be508ac7173e1cb26811b59a33439c31415349f5636061020a7bd91670f3064a1429f90243bc817dc21b8973c07c3960a909899f36eaad350900327ec2069cd4afd966bc5bbc31ba2e004fba935201a5a8ac88cf82cbebccdd30572a7627198a6264d8a8c6a202e723bbffcf092b351ee0ac3f820f387771739c52d02ce992e936b8331f653278cf26b08cc3d52834722731dd45c332197a9e63bdc851b9527e7d485bdc27d6df0a08a1e8ce7f44f2fbc944c28b3264a99354ab3394292c1f43d5a570660e6364509fbe066318fa769e76eb29116b31e294bac49611f7f6b34c432a70abe6deb47f2ee7587056e65274fcae3f1771a8f5278369f10a109c4b3ce49f06244c2b168711535f1c34b90680a5ad1f98f5d4d5cb04e6a5de4411b7de9a085a22beff27baa7587d24ac642dec73b3ed7e5f119131f7431ff9f213ddff223ad23bab42c5d411c471c0fdcfb9b41d607631e2a1068fb306854e8eb897ba26b6cc366f2a781444f44f18e1ef764f0e2c2aacbd335544f2e1f03fb93619a5c02aadfe9edb2b0ac1ce6c7efdf4217cf021f578579a70ed8de7b833d5eca5e8b80c43f4feb04bfc84e1331a3151a64c62dbc02fcee856629eaa0064299a8a85281df2a3f2e7fd210e99a68d859b5df99d5a0e41d05a704a12ab64d3185d958d37c3e0d359677b8afb0a78f4f9cf759ce29f460fa7647ab7ce509bedab316c4e5c1b7b4ee3e3419025e377e22f514542ebd694d9776ef8eae03a239e2fe3a93edb1d6743a7063eed6bef4699c3134d5b03a88fc7def70188c7109a6ce45d5a0c87f482fac54464f2071eb22073e69df72018ea7f1aad86fe9902402457812b240df8fcab4a319cf2f6d87408e74946046b0dae60f765eb271bffacedbbd77a453a976cbd1519c59a00398bbd7f7e72afbf6e1cd3287bd585ca22855c8af501178ca4455f8fe07b355bbd2b35611c11871e1b35e2431d7a28781a717bbe88928e23e3b2d2e480f2fa10f466190bb77031b120402195cde6881589c1777f22d2a4ed30879ff5577a171d1d4566728edfb04a1e3fed563e70b152f1d061adf4ae225f6beebf6920bcc159617b750256e0af3eeba69571d05ad471bc23c90dc7963894c04106d9c16f630d2433e8da077e05fe9039d6cbc47cf5e4dba1a70919892627f4d45ff3f6c5ff9c14878e805f89d26a09b00be854cc95010f4b735db7a24d2ccec08f209ac69b7a33fc6aa4329cd41844b961be50b917fcadb18af09407630c88306287bb95ce4b34e5ddfeec59d96de8cb0e8cc9254dc4ffc7f0736ed331c829fb232c5f12f5663d74d1a1907538498bb9dc2b39c82d565946fbc6818e8f120de6c1e73690606330fc9d1aebef04e1e402cee393de05020fd813de2e875c5fb673f75cc0ed0316bd4b1ab61ffaad17ff9250d72c9989373ac0a67bf617f732273b3effb1b98e3afdf1113376dc0d70ae24783f1baf11d759a80a2a0706e7c9b9c93a5f18e12033ac103c6f5fc653d6880e8b5b500fa8756da11f1b85d9331a43b0639ffc509ad0b911c2c9d9e7cffe7f0d4c2fbccb2c2209fb02e3818faffa010feba881e2fce7fb703f03430518618c80b53c4283efe48426e571461de2fb6c3a57e6402f285ba55a91a14227984b0125cdbfd09343cad727aa205b5305901f465dee2c52d5b4b582a3feb9ba5a4511ff25af92095d4723d484e1385e73e7db08ae10735951927f88cb95bd658d9647921e59763b9c914171753b30bc3fca5d9a527fa5c9db80336562d43dc8d53c8e6666e272a2d6a1fcd4b024fa6d056f1c2abd50f02166e647b167bcca0cd04e78c2c55b0dd70925d79741fbbf063cc0722a1c12d39b9cef2e7f16eec7cff15301689f20e43eb25dfa187133d18705fd24412f44c5035d00894f68c0790dd47f59a5e13041c233af6251f29243bbce4a08b92a03a7657018fd4aebba0e36c89b3f8d1cefb6c10aea3ab117bc590c7757d508a9cdc9165c294107ff198da1ef9cdd05ec21ac365c7ed5979ee8016437cb5bbce81b8606ba9282233377d9ea5539ad57ad461f8765c22794ea58911bab89aa5a388b04b3a86faaf2a2ef56a3b71dfbf86500496c29f704d5a9a11b084bed3d604d67ff2bad048efef132f2db25e56e500c3518181be6969fa8cadfa4b3a1c50a629c62c96c8c0cfbc3e023c9b1747523334fbe0d792503c4113a8a46edd6427437e26604a4605823c6a35ffd158e2b08f9e143588e185aa709a80039554d74598572c0bd3679d80c7c41410f72b10e8bd95454d2c160f5fe3bad023086010982cd85acc4ad048eea2158d66f1f280f081547a5a86079c199852053aee16417845f6f2342af48b5f9d7361787d00f401996b9e216eed962c81748c50c3718d232afb58117d2b1d8026bd2815d9f6123552ef34cd9d8f4035489bf768469cddea4009c396c8b3e4d14c5e1268d0bb571a9928aa39642fa7f82e72d23d384b6d28fed9e178521014a8c8fa78c3174366b538d33b47a0ecabe4a04cf720f6386b8d40cb5516e20e4ca16476c28f86f4a1f886bb1704b3d17605989b08fbcaeb1e3ab56d74dcdf91f9f2349312a540c3fcada45f85496e6d5e51c9a54e583d72b3887dcf5001c5c359a4efcec03157bee0f1426e7504fc99b2c9739fb79ff23d24d20ee34be6efcd54b5e841b2370ae3d6d96c613966451c1f78906f394ad6920e715887256dc7418749284c544dc63b8778639271b1d2ead53a638a35836bcd6c1abdd4a854443d6bfea15be6942f96e7386a716e55e4028a48c47f936e47c7146b417eb95e12cb2a7dcfa3c1a10c4390c757c249a529460b21d047def8e687acf61f31c0e2cf1449f5bbb6efbb9c65ab6f02a647767dba2a68701ea1b3b61118050e5c193f2a1c9fe7a43fd71254c09d03654a06379bd173f86378472018213ac82ca35a3165b398e37da4ec3e3a1576f1cb0664edc8408d9eb326f5f4e253a1434048096d133cc2de92782987fe717a63ac69457605572b30b3b3ce21b519964d3509de3c513b4e7ccb022f3b770c135f88adb5b6b7c7ca3874e1ff1230586a72a4a5b3e055687375a4027c8ecb2e9fabb2b3000000000000000000000000000000000000000000000000000000000000040812161f24282d686960fab1052ac084d6e5ea9219a51faf332c6d6d10d4ed843f2fce88e9a24578f42daf524ca8465607eaee2d930c8da29b2270184b49982d0b6949253ae8f988d97edef7dafbed39761b58c751c3692b92b5c84dab8e7a5534030578d6ca9cca3c022bd2744cc18fa5d79bc6aa1ace5f9583d418a62ff15cbab69d7ec64a773620541b9845218dfcb2e7870b068c9e4aaa3221ea8f0109df53708e4f5856632dcd8e9282d67a7257fb0b215cb3abb05db40b24fbb1892b1f88476b7efd1775a185f2b57704746119e674d99210cca536263508b73fed7e0b2d16fa0e5f6c1d2f015656a66ded0e50247919d027f3e02ecdd09f38a920d3f31b97fa4ff746b2b59a64c242073ce8c41acdc87323ada353cd1216f636d23f2b2d551472f88df404565e08450e735799b2aa2689e96e00112bea128f5605a70983aaeb13938ac0b2c174ece2145a0a43d5408385597fe3ed89c92e034a003f242bdbe7a0bf9aed4b3302b278067adaa29ff79c9635fb6005676109caae2807e02dfc00db1935c12b4533d31eb6c9ce4d2c2ffdfe28eff6b877268d1d54bb4c844528dffea10d83810bfb8c58f15a7436e06d65887d0708e7a345d232c423fcf3b055e2e026e68e60b68ca0701d5e1495fd68913ee96be85c437cad8780a0769bd5d75bd6be8fc619189144e74fb1d60268925e6f382cd2746fd6a4d262ca93e491eb7ea43e21586a6672616d655f747970656b756e616476657274697365","frame_type":"unadvertise","seq":1},{"bytes":"000015a8a36776657273696f6e02696e65696768626f7572a263746273590157ad63616c676f4d4c2d4453412d38372d50533338346373657102657265616c6d5820030303030303030303030303030303030303030303030303030303030303030365746f7069635832696f2e6d6163756c612f6d636c2d6e6577732f6e6577732f776972652f6e6577735f6974656d5f7265706f727465645f76316763616c6c5f6964f6676f7074696f6e73a0686672616d655f69645001a0d10e8559724c8d211bde45bd1f016a636f6e6e656374696f6e58303d634ec67d881a7824567ea8a2dae147fbad1c604eee868cddcae2a2e3a58291beb05bbe876efbb655f08d96df9bfda26a6672616d655f74797065697375627363726962656a73656e745f61745f6d731b000001a0d10e85596a73756273637269626572582001010101010101010101010101010101010101010101010101010101010101016c6361706162696c6974696573006c736f757263655f726f757465f6697369676e6174757265591413c4531824d78255307e70f260e8c5ca44e7d81e472dd8fab2fff4b16a513f5500287d192f4448aaa392dbbab3e6dd8ad9b1bf7adde1652918b888cb1ea8f73c1e3bc89fa3788865c6d8ce1dc5196f7bed205ae109b1a64c3b7f2c0597b0cd559d996e365fd4d71b9d01bf4afd75f63e9e8ba39d5a9c006802ebd004c4f2118dff5760ab62837ca239eb8f1aac58420ded49ca0853b7c35e57b69d31f947eb52b00cb7bfb00e91221e5f25a8bed504ca6dd4e618bda9cd2c30d18b4d21b1d9062792f6315f795d9501180911c2633fd086c253ba6341c4468c636552f10f83762e66564f079894d105e0ed83a986451caa51787bb9a19a58cbf096372631c4e1cc5041f2011b0d0b9aed040a0935c79b15ccdbf8cac32080a8d2d1bc6c3d61804678efb5650d7fd7b953d3ecda7d6591e5c8f44738f213ded765060c470be658e0b7b831c83179be854f8def261861517f7562290052b8f77e9f75353eb76611c4f92bc57e34488317f0633902334a8b8fa335617c4dd80b7fa8b7e16d2c517d6292ef829ff9aece70486becf8231d2f0144b05d7898716e0e39c0a9cc66108beaca26ce7a7fbef9542b21ed8258da0fff93882182818f11d8591007d21ab5c300b30462369a30354d0b47ae61a453874a81c6cac9e2f1096f08fd2d5c702da3caadbb4b58dbbe585f5632d0b3689ab8aa5b2e804d608ec3ba1f6f8ed979e2f6ddffb4b51a521e0032b0b4b860b69fabae86ec3a2194473102abb9dee976ac2f753bd7f6f103169e661e172f0e44bcbfd8a9288cc1e7f143aa59dda5797d476a2f163f7a6411d62925b208f55296bed7a779269ac65c5568bab29439a5b5eefc10567fe6cf80d0f3e943d772a955a36b8dc70b7fe0b1a385ac6577ce8703073d358bb1815ce9aeeba098aa674f6890fd58f065898f0687d8ef75901108ec5be53e4fefd91a4f2b179558c7003581aa4e3d9821ec47ab3a18ff17941e9e0d3af28a7a4d548e027740a626c6a4fa78ba08824a4dca119c0cf733adc55a8b46c0045f84afe0ad0eb3299d33034310c3bfdb15e9f6bf3bcf2939c3f92292ff1446c3372873cb98e2d66b3dbc10f176b12aad1674c02196d0e8ac87d171e2028c7d7a0c7d5284d95dbf0d8d4e7740e03e6a02fb4165a416bab7d0bda90d0d489ee4013408e38c4a8194183e29f6f803f1132959bad5ea4d0f4955444315539eb1c0a2e8349a3f484c6a39a1acf6ca4f4289b6958e97bebe8616db273281af94f606a14561b83c6eb314e00018a5b922807928c04f10ad0604fa2e388fc34ac8431c728bd3a811c8c620df46049cca34b0be79f679e02969e7aa329c6a383482a62abf21c23f153eadf17ca888f8d668101839188061c8de0872de48acbed2605d50eb22e4ea16acfc6e0c624be36f78fdb8d1cc3df04d8544395b71a8f0969f0104480edd00dbb9774c84ebd9ee7fd07300bd386787a3544fb61eea6b7daddcff01ed178290abb3969c3e09106cc171c2c97483fb445f2181626b53ca2ea6121939b76756213f877818c5f98a86e499a3155368d13ee58d5cfbca4dd41a8fa519bc013abebe696eb25bd5621c12556ff050f963bfd242b1eb9309696c649795c387c03073d3cd4862dfe30c0ff01d9e5647ad47676836283a9d7f73d18927f5afff2bf9286d694f8824691b2a9dabf00c5e1e60c33f80e764b9ebd4524d67cdc07cd96ad65177bf109526d5b99a21ae67dc82fcc2d0eeb7dbe49bc0bb5530092941c268980e52e7cb3b25ceae2a26d593e97f91dce64c9921efe1885d5a4ad2acfbd62e0a13bd40b121d175efa8e10aed5693246e054fbcd520f93c37985c4307866cd505631ad1d28c794f743d540bb792a342bc1d8bc7f1700043a97967429438a9d74c9204a42a28a786b68d177afd5382ca911ef66fc64acd9713ec2edb0cb0e49794c73ff89dc25c94c85045fe8864338990f0319f407abf462d885e5c68f71bd30f67bda56b98b192e701a14ab670ca9cc54a288af02f55c44b83636122e07f19df2aa3fe37a8f0ca140f5163308c90a637384e0a3aa318c57b954fd727a26af28c4da2da798a041baadf958e5fbfcc58744e388485655712cacb6ab997c324e2c1a3f2836ca688042f188917c4d2187554c0f10988a7ac5b68321ed18548c04c377350098073d1a20df3c5ab7ee65fff42d52f822572152edb5f82e00168dcb8e5d5797fb1e64cdb90c5e97f645d244d1c83d596f368c9bbac0536a188b9f61dda755e36396315b1b860117e11f57a5b9a7c268e4b8cea89eba4060c4c05a52498026915e46129d633d239e453547eeb2c35603c0b918848a0ed21c7b1f28e7ad31c071e33ef02961f68f9efac344d506521bbc972ad59d22c3967953cdc46e8381a5be980a10374a1e00854c5321f7a1df7037c92e42859e0b032aac0058ade28c88427f6c286e9288e4071c1fa840f0602b0bf817b809b14836aa356a1f7ac5374f3154d5077315fac782ebcde8c15f3698780ed9bdc3290d530d0b4a1bc62da6c0c5af7e93e29253bd407d6f0d8dcad3d656e6d772f5beb7bf315a214ab73c185db361fd23ca41cd5391a0008c12a57960e7c22fd941091c7ed183b7655c84bb05c80d725688d50bd668d141f8438bef176b14b9da56c9148504dec99c574f40bde29b6f287cd93e5a0acfd172897df72e25bd9ffd620c7f4e07c902dd7bbfe7bf61b666f926e594f6a6074b8e31a118bdad16eca16982a5124f1c7585cb11266c3a386e3ed0a9f546084def30e35c07939fcaf0d40ac503550e179a3082c02a24bfd917a8468e0b888eca65d4bde6ed9b121d88ce7a2a549cd476a1966a6ae94933e9c02f2263e53bd81775378abd404c4173fd2288638268e1f96a62d63524a7dc23f6316b50130dea4c5a0c8f0c9d74e7ca4d9b08417d35cacff401c40741035a52ba391a9c8a646e34bf0944246a7301ea8cabbf49de11f46b99de37c76a932db1aa748a516b92c2cbe029ca85d058947a179c713de2fdc07039538dd361962a02d9b4039deac6bf480931a9f7d93812b85c536649c3387806abceb15d5b5a242a02e1a63762dc6c3631b35bf95b91a977ea0e2167212211aa30ed44542484f7a4ea7b5d8f8c915fef434c276c0999bd89e236a80a5e4e7c9cdaeb677f4a61ddeb564e684f697f36ca2dc6af618715357b951ba54e481fac02b527cbd3ff88bdd15adffdaceef662e73b5f2a3a35dd32e1b47d799defba0611fcb5d86c8939446bb024f638f1c887525721282190cfa73e3386ca9abb57830d95e7a7916de5b033d17b0b349c034b5ec0d906bebe818fe0db94a02082665316907c720dc1a480092479ca5e370411d4abae02e5d210a7d8b15aec48ff90c6d68e531a7e40d5e87b00a30878b19e15b5d9df594828d1b55322dfbebd55dafa94701346e4c284bf3db6a20f2591828026c62b19d750963ab1bf1ae69e0254dc6eb2ef162b6670ae26baa81b8cdce9d57eeedd89e2f57031de672444d0cc4590c49625bcc3c0b9dbdaa961c8a4917415ebacdf98512019e73da2b155b3b3722d2d1aa92d39f6baf2ff3fbd2d08b552eb6af831e52e21c857f6d2883fd9be1920b60f94bf4c0c75a96769559cd078539c23c166a4038867dd4d1402e0ce9d0194732b499d05b2a51c58f5c5cbc1e11a5d15fb5efa438eb2fb482b594bd7d90c1885d145ef56780559a57d4451fb07504e1f235edc057e1646e6ca9aff11cc0884f4a45897edb7852b6fba1d4a48261d0dae693451ad77fccd82f6405ed870bacc8757a3bb6ca0e6ec94803a0995c1829b28881c95f560132e34e3d385f4d9e54cfce2be3076ffc92fed75687431684912be375a6f3a784b3d05da4515f699f437f1743cca66258b28bd46e25c2d7ad3af3d5f8bf229027c2d7a7d0f5c69a18518199d4f9244b70c3716c58bf8cb92c0e6b350f6b4d95abebbf5d036f3d61a63c05d725e4ad0d1d71183f6f1ce186c0d9fd96ded0ce778ae3f920bddd2cb90c1d6f4ebb4b671ce368f299feb3c9e42ba68df6c73e2bdffd9fcfc18f11153ab8b600aaf20f08c8fc3f6ba27df70b0ee58d0439a8c49a87de01bb6c040ee5c7905c8243c980ef11ce2003b18bcfa8ea11ebbe06d3a0bf45f0b2ced838e08a2e682668e7d7d21a6cce5920c6df92e9331c4e60d882ffce6e7d494b20495b48d0c216c47eb3c3155623ff2f2db3aa065dd5bd99ff20cb8398ff5d4440f7ce3d02dfab0d62552468fd32e6b691533d9c183238e5475c62210faaa7aa3c1af1a0f8fb57b22de8c456a06e936746985be4326dbd95c8bf062eed4877f88cc74ee27202ea816fdffdb34440672770a7d7f029ebf2dca47d8fe6d88b4c6fa68a05b78f242b36e6bbf6cb959fa9e8d8d80de84d80f78b4ff96b91e6d22209bbc445eb6eaca545405d139f649521704157f48ac7a1572f0e51fc94aed324ab5c699707e301e4881b5021670c929e8ae4c76e3d7a6d311617f6bb51f406a111363bb496ce2c6a1e6d5bc01c803119526fa73ab794c939554a8a0a4b134a62da774f1c58768ed7d56f61a6f3eadfbcc29236c79d8cb3f3aedfd0cc94927b18fc9ff651cf3614536ea9f91cf66168363353b168e7d58afe3d62037b21a7b82ace72c553d3506f541dc0095487fda3df6caf25cb5fa3679345b5e2bc91711edc1bf87bdf56272ee8b19e4ad27ba6ede258a0c40ff5f18d84c6dba265ec61b481664615f642623439db641f76a576d555430ceed5fbdfecb1c6f45ca48d10de474970859ab192d694bd181d6b373d4a2cdcfd3884492fd6746a405f613bd10542331e7e5d1db2d47819942cbc11884a7d3965a10aa693698f95f15bc44a1d416fd9124fa97cff47b1de7546c36f92bfa784f18ba7fb58177e820b9de1f252a195d2c56dbc85ac4ee0a95653dbdd330c5f1f0e688d5b012d3eed09e2554a5ea6eb028897027ed552ae085d5145c894db4e4b98cd10f1e7b974511a2ceeb1f54964c062d23436944e3d230257867e1eeb142fa9f48ef98911de9e25383ececb4e6bc588c59092d04de0cc3221d2519236d9c9c91a4131793f3668b03a4b18e533634650a33d1857bc4bd0707fc2f641012ecab4ac925f510da0192341a5498941bc758baccb8f604e5fc4633729a0c4d6b2c5362163da645c63ffc799368236f788c03b4962d6c4b0a815a3e665c9bc0c603e37a344f6ee9e0c7169d61b170b14e36d31c816945ef53723372ad1ea87ace3b8ab397b6614cffc3814069cc37971956dbf8ec1602d3e24d149d86158908e11d98779c33e8ffa935d0df12455f707cf3e74749d0afc7cf9e993bc7a6adc5eae97ec608edc52a0a2ba3a8f876783b6b436dd06f1f40798993e35e211cf67df07463ba608579bd5b373f813746b9078316e75cecf61dbcb3f7cc56f21070783ee569f3755d374a59645aa42eb8ee636a3463a8f61cde405f9550c46a116aada0a6b1e81beccd14f8dd58ef9460ab7be2c226eff09ba13bb3ba89e640f256187f557619d8b5b39cc2fd47efbc627d40eaf796d1232386b8ba5a386d612999ae77332e13509303fd5f399833fd545540372a1303d63da926d7fd96cdb5fccc7c3954017b937056cd9b4930bf3dea2643eeb87a848c27205e769e7ad8b7a742a66e2b9071489cf68a2ade13cc845578cf1637ec02edeeedfe364b3d989b7f7007732a9a42e323d2b6b11093705d668325eac3b9eb7bd041b831fa80a498ed0f4b7f5d78603d880bd4a040121621c1444b15506596a7a16ec93e13f87578cbd0ffc4577efd0f63d7565826cb3a87852fe8f4bcd134111bfe1f7aad8a3b17182715a8869eaf3cf995714e1099027dcda5e25ef01d3cab351b36ecde09710b48683e5f542b009d77e32bbf42b970f8b634f6c968b02c14371aab0f7248d9f32d76acb2c59064e7cdeea0dbf0c6293218ff3bd60a4b5cf04e40fd3d3ed92e77890a624b5d53da6d25d86c181accfe9f986fdf58009d9cc0dfee65610d310cae66988461e8a8dbc1f1769c1ddf0aadc720de15c177d824b15f6a17248945934f11eab6ccfc9ec633f284ee4a91fb62158f4673a8d56b1dd21b4b63d5c85157d99b5090e0b1375d10748a7ef0f20f4de9cc7808297f446403f19bfb78530111ba7c5a3ee77ab9240ed57eeaf23bbc29790c240cd62a12d62262adbae577b1984151725be2d1f3b2ac06b1e53b9d585f314a95f0358d6dfd1708d83fd6afc8687b276773a7e4c4c8601c4a9eef05903324797ee985c929ad616e93ddc2b317359169623614350dec990a0c9bee6c555b63e81ca09262af4c0e856aec3c53dc68d7cb26bb33689769402581468cfa06d1e07a246cb0bead1fb85a32f881f016379075707a4556c93ef9af17c5c41cd45dd82e899d8d555545428b830d6c471369f5c8a6ea5d7fe17a3b2c6ce0e2a3a6d71728b8dadaf223b4c7e8a97c4c7e9eaf22835a7c1e7274b4d9fa1a2b0da232c7daebcd8db15203c73868ad30000000000000000000000000000000000000409131e232b32399e8b61c9006b19367c38a42c622ff7b3a4f9c1fddabde2b638b1afe5e7e10d95520593e289b536253ca05f2397874a570bc40c720c4b8a6965388e63323d4b8bf71879d389b420b4a5f8020da5956338ae1efcb12d87e0ce7a8cdec713bf08a6f54e4fa5c9d383ae994ed99522cdb95f640c4bcd77e79ae9d66c315a9ba1d49ef2adfd1008be674e3dd721ab8656554cbad29c5692729533115a4a8c1c603ecc651f8d100bb13514bc2febb4bd7fd252ca1def91ca4e740bd0c062e2e4590a80cd94d219dec48d6610a6011f0ddeae47ad2d450aca9ed6b827a43a6756526d41b233fabc806b5accc06ec1ab7fcf04ab813c938ffce7059e079a88d279ddd15e07960919ffe2c2aa7d19de55de8b4205de999422bc3890629a26a97f39589447e93c3bb0064c25a1e9d939b412b60b9e279c69245231009be3bb4f67e7ce686c37722b1393cdf4f7684ef6ed2500a509ebf810c9531f3c0df5ac550117b70294709baaacee42f435e87e2b2b3f1e7b3fae164e9ac95dbddcbd3cc730d225bd287d9cdd1a710dd614d898555442047e9a6e79ced59cf234fea02c25e8a226b41a9e7bc4ceeda317e86acc3f02a0e990535fc6edd573a7b16f78b7bc11291abc28a69fb0d2294bc00bd2baf520d852ef587526c35a899558510069fef52dda24f7fd83b49f668c506c70e4531c6de612cb582ff746af82d44826bf0571c60da03f6a6672616d655f7479706569737562736372696265","frame_type":"subscribe","seq":2},{"bytes":"000015a3a36776657273696f6e02696e65696768626f7572a263746273590150ac63616c676f4d4c2d4453412d38372d50533338346373657103657265616c6d5820030303030303030303030303030303030303030303030303030303030303030365746f7069635832696f2e6d6163756c612f6d636c2d6e6577732f6e6577732f776972652f6e6577735f6974656d5f7265706f727465645f76316763616c6c5f6964f6686672616d655f69645001a0d10e85597dea9b5eb92e71d73b7b6a636f6e6e656374696f6e58303d634ec67d881a7824567ea8a2dae147fbad1c604eee868cddcae2a2e3a58291beb05bbe876efbb655f08d96df9bfda26a6672616d655f747970656b756e7375627363726962656a73656e745f61745f6d731b000001a0d10e85596a73756273637269626572582001010101010101010101010101010101010101010101010101010101010101016c6361706162696c6974696573006c736f757263655f726f757465f6697369676e6174757265591413ebde04b03ae2e54a8720f7230032109d388953bbf183458b62821765fa50555dd5ff4faf19772af007e5f83047f3421a97520d1052103ae68539efd7105c646400ff74c0db945a632335c53bd16625a388889a7ca98c5a1a4fa3ce44bc5e03a811abe32b86d66342ac6d04c65c818f19053e22dde0555b73854cf17ae1273f7c761a11dd5bda45e728dd8a393df5e5a18ee788693b9f3e0cb6ab1724d3374d2db46e6b1c2b4e84970238dd39f15ad6c224cadc9dda18032fc8554e52ec6b4de650d450d6f5071180269935877f0ac5ab96b1bb0531251a8a33b9f782714db5f8e2d4b6f935b564502a834e25e68cc73cc4e5a086b0fc769891a9b7c7500a8b21d2d18954b0c8ddacf4b4e7d011b3f314627df866167317b6b7a22af4c2a7b1325df906fdde31a5a4a2edde666338acd13bef5aefd251204160f039baeb8429d7978c321b128709531b5e8071fd400a9a4b1efdaf3cc9890eff493e1b3df69b7ca90a9e3f5ece804663fb02581ba017c05863e9ffacb8c00a803b044a491746831574fe7292e188eb04e95f3f7e0f20c82098ee2c8c0e652212b2009c5bcf631d8b61621881f316fd612626d38959f2a30c4234c9e2a491de66be18bb380cef9e734b4a16a04eb271e285d8e96d46b728613eaf426dcd51f540afd135519cdc9c3d5514dbd79ec2d85cdfedd705bbe9a1f6f9e52bddb4cb5ad70bc1185bd139ba081558f5ea2ce832f14d6e33263cf7e15f28b99c741435e112ac71af26db087f9f987506629b69ab6e5030d0abde0f02d27fdfe915c84ba0d23a1f2ff07922c7206bc1972fd59883c1ff1b5f895ddc1466887c291446a66c001b26971acf21cb57620a652b04459ca2280dfb4e97a5f35c645e3952a3e02c608df1fe3150a4b034185722cf6f4a0bf205eaf7112aa5373ea159954e6458ec93fdc892f500913d2b2bc8bd28155c6f92b8bec7071516db099735ae058f5b5cf4cb322939873eaa88c3ceb824d82a3d8eccb742de4c16e3f89255ff2922c7e3e544b437f0c9fe10d9f511ae8297f413da235516a6c6b10e1a4ee0699d3ad98794c2ae557449ce183fda29433da07e0b4658d56462798116363fedfbb22ddfe7c46cf8fec222a124d8c4ae80f58dcae6ea1c76afa4b552e55f6c49f3f863c1ae4269978cce3d51c0ccf8d08ec3d7595137d616c919b96853555b2ce6c7f1709ab679aa2deaa4d26908769ce98792375ca7e34dc09ad48c66a36a8ae9df7621119caf645fdf367df33365c5eb50f67b84847dcd6fef1eb2c27e53a10f1fa8e9baab9d41679c8ef30207a22b760887d3af10192ac37487cdb03f1bddedcc79fb562f294282d6d8909e8759f5402d9d649843fe155db26098ed33feac0340542fa63fa388d1c2fd095c4d7bb18ef65b1a3c153d328beb6661962674732c0c12651b80f86de3f5f6e56b6fceadf56801cf8d045c94a84c94810c41ff23bfdc42e56c58bbcb329239bc305ee2514c0ae9cff2f8bde65172f6ba0c013f927bec8311404f272b84b145e3594b66daa5432cac1df4e7053f98e7f434ba4d4c21370846248ad23be30ffc34cbd9c0a5b2fe50bbeb33711718cb4c99b551036493fb1db3899c8fa5988f24d9671d5018b32822f1bad4a3da0196a7c20be504e757680640df8bb2df8a1621cf91d6751f32842c540660a3bde83ebb48c8031e36f1b529aab46c1f0a07e0e806de2074e86555f25d415ad6e8f43787c9e5947e7d256817ca55809ee4b4b5153034123c63f320d2f085bdd06a892bfdb6d21e00096e42463dd5c3d879bee3b665b35605618204ec7e4e97984f1019c00d5bf81cb885c3871708ea7995d07e3b6489f4c8c8c89b9ec29a0d7ebc76f2f54b727300bc0e0517b5097c8b23a154494bbe6afeaa1410cc57d1c82bdad60e6bca60be16ddd49fba02d56df822190dccbec90f97ad42b8133d593e3ca2a8291fd558f866aef0a59ccb37f04e5565fee38f0fddddd0afd1a9e451b92f3146a63e3bac1213d30d9692270c8a7518ebc6197a76f3b389bfd012dbe1ef5df92effd4c3296a23d5439368678a9e47f0ce530645802cdadf7a9a0d6a52bad58369bd748705b0e30ce686540f56dd9d6de6224eb8c3db10361a90173bff3143041e505dd984a5c32d77913e4ac56caf3df715c6267594ab5b832f372aed8d3807ef41c87cac1d3befa90692e489aec1cb0ec734546e73bae3fd8b84834466c7f9cc18681046b8ce1af15a13a221e94ec81ac0d5e3f5ae2279460e9422452e6c5b2dda9768d37e45c461f03d342c9b237b3fa14f703043d9a7bad0e163cc60addc7929a443f9bfe1c2336cc5a89bca9daf5f98bcb165aea8367ad8c542b1f549dc30e175b6ba8f925abb070ae0c76f3b525862d385049494bbe47df9c1a075a40ddaf105b41deb91178c11dae252617565a08fde5f2a1dfefb12aaee82bde0c047c99f50a4360392052533988a24361d9ea1f36f545753bcb231b854c29dbbc0f27a920ebc355872566de632a0dca792dd77e104e041b39530d2171e4fd3c152ca9742835ea7eebb9cf9ec836bb268080ec48478f3caa920af1a1bea6f34371bf5fdff600a328c53e195a48224166f1fb2b877b093127d7d90414e9cc9e75b3464f8bac18e5c058f1790af9dce06439033f920b94f910a3de01bd4ffcc30747df8e5eb28c5274a6db4ae68d4aeed1dec6647cba76008c9fd56a28e363b935c709453dfea51e3d22b7551a5e6c9ed5b3508eec7c5e1191c8dd37e37c555e0a83568c242325a8c8bf62d84210edb10040235c37831cfd002843cedde8e1606ed0d5819344372a3f1e487fe69366ddaf16187393aa51dab36043595d359d7e89216d01fcf69d6c07aba04699e43cc7af3a0006c1777bc378784718bc9215c4db5090089c43fcb0f238f80bf6be912c60e84b57988ab9de7c113c9f937b9dfb92c06f20d62a023b0743bfa959532706fc4115f534ceed1db64618a9c35472b7e2fa5dee68584d569ee4fa619a324235bfcd8e18ea87367f3af60cb21fd57acd34a540acfb07a8ba404b27ff2c41abbea3daee8b9bf6e4f6a4cc95d858d081253b9dc0ffaf868c33cfcf9c7765d0217128036e97e8f723ec260960f236324d668a6e31f54f3f4e704cfc7af9b8b1f12aa9a984d1f789a8b2bc6eddb0655660e0a8232dce38e486d8029a5feba8f80d6b9565052038b68aea22fb1944b8b6bb387e55e7c40edb6ec9b9b557dd49c2683b3693d35f365e8a7eebb6287685268579759014d46f1cb43a32515d35eb412dc73071b09706e9c6c9e3e4d0bd0e008e24747dc22803639038ebe6abefbafb1153704ce59431e40a408b096ca11295e1ffeec414af5bc9e18fcd15ac2b599e5d3fe88746b99669c0d5b67da481af71c5fdbeba340074ccb7563b64e89ac72fce8517d87d9f9674d3bb5593c1a5626dac4f9d73efb9dc1646043a2a58d160e48d5ebaa7d77c42e66f977be05e8ff860be84eef217697847a4eecdb1ca3bc942fcabf1b490cfa47c9e77a2acfe25b894c0893fa6d45faec9590c6c6e61cf1dfdfddc2d0f97af4a4704a8b34740820ffb462170d03b0a79eb1a73e267528a57069a902653f1ef2cd5246e42cdb4d2e16cbbd624a0e161c8ba76369f24b54d23f005c2c819bfdfc1eba2af8ccbacb343a135743d65708bb36fa04189acdd53a32305898e62c79456006526497e66adb504343d7f0607fc493498203062b8fcd46206bce27862cc6f0c1d20f7d4a125b1bfe959df323e0b8fe02af266be05dfccb362141a1c6249c7bb52da2b5fd8fa76047a4ace8bb26c3022d86dede9115d4ad3e75f58436062797c234e07d5a9f88861a15365fcc921e1db2ab17e8ea896f33d93fbc96e2d950ceb73e4982f41a099b37535fbde81e10f013d90bad3cbf38e70358089091b5fd0cdf16f87edbd8fa9a7343bb815c04b305c56b48c046090a08375d06c660489a47cae2e28afaa39d2d9ee63c0ce51746bfe5ade57aae0d71ea66ab17f3803f59153e0e084d9dc8d7e0cc79f3f1d005373c1a9ad53e5701a65e436f3df96d145b8fdbc05b9ee0ce7812a9368160a24246e86f74787a71413bb7a6e159030c678c4fcacde52e71bbfeb9780e4de966a7a8a03761646e145ee1244ed5d80c551c3709b3f2eb5008ff6fa23d7d0160cc500b525110c0c82d0b680a62aa98d75a052c3710709f106a0185a786615f34c6ffd8f8318b93cbd68ee57e39eb4d2efa62c3e59081977f8bd07efbaeeb9835266a0b9cc4e0fe143549ecb3f2476850455ae7994af1f8e5e8587fcc143d870a0fb0f148d5fc0422800778ae11dc2c351abd77756c5964d75edcc8e5b1bd0cce82ab19ea91b54d75984f108ea41325d359c1dab5933871c328385099871a68bea35576deb18b01b9ffa34dc260f661a659104219861662ce229847a5542f83751a172077cd17526292d2c5fa972095debfab6a1b292742b4f75f9be81bc53c3b38b54822516f716c2b50c568067c03d44595d470d06031498d56be3fcd5be6c2123b0127c22a7d4e3a1b30d2051baf9987c758b56657bfb76d11427c520b0a20f1cef00c16f2f1dd13330f4cc50d65c7fd435a5428adf2df4717efe387cf99155507f7048820fa53e74fd2e6889c5b34f71ef0e7c1faaf053a0b18d74ed7945cff6dd87872c6ce69f4e11354b74e880ab6320a91f6fadf0a38928d3cbd243fe1f17ef286e8065da64590552a9412d75e71aef11f8a549745c8d0f6eaeae4d9eeda50d207a05db7d2aee4687bf0e8986101a67a05a7316c6e4466013f3e0cf2fabe8853bdfad78a7d26fcf16c4c4d3c7978580067af549c5aa193cac523251ea16d64bf47753ab97840db36dfb1613e2aebdf5b4be2f931bfbba62f35a8e37d4998a3bcfe5c78e049a211a1c0037761fadb666f99a3ae7b02056d99567664b7d0aba55eab3d02835d78e1bb643b65b45b9c4ff162380abd26519614affda6a6dc0e2585cf5e4590f75d26a881c0d4ae3435f8fa8ee077e7dd6041ce83b3575bcfb78c964e72c63c80664ae45af1c64e1a40dfce98a8d08da6ec68339392aab1d00bc2421cb637d10eca2f785b0899d3045386186cbffee6364a0102a502ae8b9d786428b9e7433fc7584698ad533b0f7f452c90ddadfb4bfcc833bcd5e8810ea886c74cec8bdd9cbb4070a9e773adccc36e6ae9415098c5b5aea7510bc4d87d233e24355c1bc3f636dcf96b95f5346069514d2c019ec39ef06c6a8ca2eefb9357940f87c5404babec6c361656291d331514142ab633ccc29fcaa23848960addf1f81769b145f8025e9df792462bf79a06eabf53c9ae07d5b198e5f84ac543bde009e6539d464ef99521236581b3cd3a92c789aed42b29822c0993adcd25a9ac17c110ce3fdf39e40c88342cdca5f63fe0f0e0a752c05425a5b408351c129075dc12a9705d74a74b0b67bab01aca19111675236ef4bc6729fcea1e594811bda882ef11f3ef17965e3c8b1acf6593c0c6e0c6bd54487de1a89a6e1d01e2aaacb3dca21072893cf4346bff0fb8d5ada04a71313107f63ce9e340d9e6cfc880f2bcc80ab917bc82c595d61931a41eb46a52360b4f7b08beaae354a4f4fa9d7a4cd488923fddb76c4ec00a2df386fe7951a59a362944af7891a79ca5617b1dcfde55d0c9da0ce06ee5589bacbfb154f342b6e7170513857321f1297303de989f8c892e7bdf3eb888294f3f08643c710a63bbec771570452d86bb63d39f76812bb1349b6a01ab5c493a5934f5e56363079e06605fbea574d6b342f697ac9293c647d0b2d24305cc7f0678e8ed0f6b4b33bcc0a834fb7c746d2a180e38fd68143a94fc1d435298f4e1d016a74c6a9c1fe1fd114ce7807e2fb3d71ec8faa4a1c6ee7b9b0db4c7183c9ceb152ca7d93a6155bb9ac17cbf8b9c960142cf74fdbb3f06782fb46b16c9d611ff0b1be9b8300bba05401a60be5809ba06732f005683844a7c85dd78984d4045110510112488531a8f7243e6fc2075f13488650198b0fea8569a51bc802a41bd294494f370bf2c2e01a922eafc2dcde8030e7f0dc9e666bf335e97084979d31dbdaec6b57d8d4d5f736220d37f927528a6ed66104e47f51b37bf94af1844abfc0900520583c34508ab54e27c33246013666cd7abec9cfa6288dff261a4aedc90894ee00f95b283c9b5a6b23ba6afd36c39d6df534f6960dc9eb7df3f7d47e51f3cea0d24f3d4370acab78c9ddce2d6251eaa3f7a6630f047a925cdb0c0ea400055857853bf0debd7314503755e6727801cdccc835a24f6aef820856c9fb1e0e3e60b6376108f654b76e7349585f9d73f37042df68ba6f146bf63a6200d9f3ab9411e6cb0bbef220c7518226156a56e1ba31d832fb73165c9d242595006c3e2e837d1c06ed353db719294d3b29efd64eca4d227b6d0363e6d6eacbcd4f1fc032025329de9fc737779860c0f101429414b718587abb7ccff364b566bad103957aebaef292a509cb6bfc0c7e7000000000000000000000000000000000000030c1317252a303951b586a6474d8a5b7c0468c0e2b429126cf46fbd02b7f86a50f79c232884a565dab305b980a0e363fd88edc1a6380e33989a1fb616d32246e34d5746ec4064d442764f8c7b7a96d86e7907c2373bca6f3c3b20e3ac9362a6ddde0301e190b3c9605fefb3a2a44d6bbbcb92266f0ece21872c936bbfee548e24ce6d1580a4c27bc8b2165c4807bc571037b9a6a5dfa9b0a32308ae72b308b16c4feaa33f067d1f587dc7f75ab2986913e15dff5c11c49b52185193726b4a5efa07e7020471e64ab59d622af2db25aff9d806b74863e29335563596d1576d5d313f659b90dfab7088f9f7311d4a84db44d64cca0a110c58a133dafb0d19e4bf73199be75ac780f2df71c30fb0da16ba257784d641afacce18ef066e3ab682571454283adbc735253886d806ffdd6444b9ddde48e2f65b22b24a09ec318179da2b2a38997ee10c4cfbfa3f77972b8bc47ed8bd016beab1efc10905997cde47aef04ed30be09341adbc09a0a7d405651ead71e687a8724bd0ccf19aad73f659a51d18b342978049f00aceba72104646a1e89732db6ea13fa02a2268eceb0af3924c788fba7f958fd04e2b76d7788f850c8961260085f844476b7f0349c54c7c49c061435160630523774c63dc734e0aee845f2968080ec91581a024d8b292a12d1f397b24851960fa1d876d9692f805ff0ff96efe113f473682bbf2ad2cb76ca39e5e482ce3b5dbf96a6672616d655f747970656b756e737562736372696265","frame_type":"unsubscribe","seq":3},{"bytes":"0000152fa36776657273696f6e02696e65696768626f7572a26374627358e1ac63616c676f4d4c2d4453412d38372d50533338346373657104657265616c6df66664657461696c47636c6f73696e6766726561736f6e666e6f726d616c6763616c6c5f6964f6686672616d655f69645001a0d10e855970658fb7f7e9e24711506a636f6e6e656374696f6e58303d634ec67d881a7824567ea8a2dae147fbad1c604eee868cddcae2a2e3a58291beb05bbe876efbb655f08d96df9bfda26a6672616d655f7479706567676f6f646279656a73656e745f61745f6d731b000001a0d10e85596c6361706162696c6974696573006c736f757263655f726f757465f6697369676e6174757265591413b90db838c745d315d29c7e1589519d492ab6c8a6cf5b83f39b3d446549020cd0104500a71fb0cfba73ef41b4c3d27bdee28dce9fe5a4dc8b6cdd6550d773ad371f984a6e57026c391fce4e92fb1689107a08d901742c30c536be4fde708dd58f150dbd7bf82f949101502ab7e3d24abc6be93745d5d89084ad956e07514adf38b3fdd9d7ca0fea56a3f8d57e98c1361bbbdcfb58c20a0923da9dee819b076d073c33fdda4a89d4c503a46d6a2076fb28333300e5ec287472821b0db3e4f1277eb232e5f2341c172ea5a2432ea19d497e048fc0349916f6d2a1cd5ea11a0239ff92b1565d2ae9ae85d0980f6ca6aef688808c3c6dfd97bd08f118045fcc184aef8f0ee8020d31b494a832b54f4e31af3b230f9a35536c53e5496ae39821ecae6f13f95e9cedb784a34d5f09f3a040450c4949c2a032a8354f0043205b63a991cd0e2afb52d94edea3ca30b50b642b06648c12ec07cb18a3a48f98289b7845c911601e3ae7a5e6232f8ddb96f041ff9973214768dfeb6a9ba09ddc9f2200c9dfd945f60f630cdb7bceb0156caa964c3084bb7190c829ce257dc887a8c05cb4f50ce2dce5295787a724e1675c14eef294dd99b79e6a90584ad3d1ba9f40e258ade023b9380e78c1715225161b8d4ac56a7d15f690be1676af197081123f68cf134a878a31238292f30137bd62cdca07bc403f5587e4da2f6deb4273d8284a7d2c63951ab07867d3dd17e6d4c5238fb4e443912aec85e964df05ce579708f32bba10b4518ac248b58b6c0fbcf0f067c73e93d8c00857c733f42592d30fe30c50d0a831b500730550b195ea3f662c37e5dc21f067567d780c4cd51bf58f62713bf900e473909bd9e683b8700926c8dacb18ec72ac809ba9de9283fb7799ce03871d829b7e79d60827f3da738dbd3fc0454a70c85e4c002e549d0e32501d28508e86f3c25d3b4912b9d5c6ee8d6138ef18d4f341a7595537c837cc6f67ae42c4d7e0b5cb785f7242cc63fde100d06650e785a51c87f95ad040373394b5463a30b2a5c55bf734b4e26e403902dc1ec3897afd9e3a823bf1d5c420ee1b876e149dc163e4a0c0673abaa695a852c81f127771e5a836f738aea0c0b6b38794e4a448e3805d4d62e849cd4137e0a9ad22c9bf6ce57cb8730c29bc6a698937af8fbde008adf744dcf49f30796cc12445198a40b0b4f2a3035d608c51ae3254750515aff570a26c902dc2be64292f6fc2b10223da37e3355005e0cc58ce2c95f2e2793f1d4bb1189fdcafd013b105c99566ebf8832a122cf833c71deefa021fcae04102f9f41c403d3206b027a26b8f49976ede7617fd6af4ef696f771f34cf924a820d7960288f03d74477f41ecb7b99e854814f35eda38f13756972f74a43b6118f95a1ab68be7fc56cb1b43eedc3ff050c5e14d61720dfe3be73036775ab69ff97a8e25d7b6ead690f714865def96fec5200bdabb2429cc573993e23b92bcdc6863126609026331159c65d096c923ed8f42dcf7cea132c7e631d1a8d5495b43506eb7effced827e4811984ceefafdd71458e53fd24da4d3997c3a69ba04ba18c22ad8c02ac8a37b590c3fe5f8251abae7fb61b53fbe8e4779bd943ce354682861fbb2fb06e4b3f2850b06850b77bc393af4c64fe56a9ff4641a2bee05a878edde33d62b0b974594ba029bf8e017c55569b2675bb1502a58296da99fe6e392bb951acbafc2524f43c4e0758c14ddec0ce998755eb4bf20ad7a474f7e271703208a5fc22d65c4f63268e3c5e37e1b1d9f45286484264f85c4e1c85c715c086d585e208c07b9b238e7c60688d32bf32205c82d82c9b03edfeb054ba95ab6c0bd925ddac89b3eb8fa037be5b42b337598e6783d5ff82f1cd8f07f650a4be00a8d873a2e9269c10947a76a6446d101e85dd75f315ecfddd9382ba995f0ddf0049531ba9eb038a4100debac710129ebd94803e6be08e21745469768772320f80f71c368e32f2b4a606c321f2b6fa34f388a131d9f78d432fa1396688777e512481400999ec8cf4b51eaa53f25af11f44c947f125bff001fe38a3e43ba97314e632e34937d9f8d7abc55b9c00bb2ed2291da48a41beff6acadb918ff7de72ab9e1fe2326f0a2e846f978e9218a6ab400f4365b83847cdecc63e6a09c3f89a2a9ceb1610be811cfab01c0fdb1109506e505dd6d190b96434ad573bfd8b1d9ab7a2e2f8fa8415496c6469109e8dd1bcb3c16fe2d87aa4106339958b9220e8069d80ed3b75d3b9991039ecefa1c5c461b05102b74a9a746c96963c50dd9d671d91cfbcc295fd740d937f598c2c10ed785ed5e458d293b5140f34685fb1643e971a074f242821b79c41a5276dd63ddab42bf679e1589d15784b7b21128c95b36f00b4d65ae3b57125c8c8a30052e700197d05ef89439592540ec7b391f993abc2e16a939e5cb5a404e9d12663fce994200070c9d8f9614057e8672ebd8a78fc757e405baf6a979af32b8bc4bb675a4d0bcc6bdc1904d51ac72d2679a1f571119acefd138ebf2c21a8f5b05277da1fbe916b107d6791eda6331fad3915e33b12ca586b04c173195b6b3238fd6827df04b1e249fc618abd3191cccdcdc4b0e9cd35561163432e9951f51492400d0be560a9c0daf0b0f573fedaaeaf942d66c0ea3c2dcbef6f7d81d3e18bbc8f360b5f23b97c6328cf6f4fab4ddacd30fa4e27194542c4eeebd7b470f1f65ea9ea137ee649c25d768cab8fe1ee1f63fbb23f8b92857d81df57ec4c02bb760c733f8645bed793281abfb313ed838f21d1f67495097d20fd1ffc05a08b119dfa100c78d4c134232769a1abb3581632c1299d25c03ceb27c469d4284030381cb22ef75508e1816cd3ecf35d5a005f955012fbe603d0a0d8f7294fc94f20e11f7c62b8eec1e84fafa322df8dabb37a3240fd5ae116cc51e3e302277be027b885cd23a312e56a1f6f3382618cc6f4814d69ea567ef10c7bf5b77570a3135903057a6e399aefa4a413e012c707bfb80c7af12571fe05666476c157a9229d09656693ca5148d613416797f481f7a94a47b22bbdabd98fbf14500d53f9e80f408e5edcd4d6ed2715a012fb5fff56645df0b6d5957b4ed24812c0c0f6c959f556f2df2cbfba16e9774057a3869eb4daca3906600efa495c5a47ac30c827d84cd994fdac0854c05667d97a8c767243251bb973860a15f15cceacee66109de3d1a5108766a636bafce7fe3e0c68ddd23d030834aa82babbe50b23dd6b4ae4e02cc7cde3ccb3ca65b4999a59861441ee10ae90fe81c18349bebfe7ebed06c23d87661d8dcf83ec048ac048470eb585aecddb721f936d68f1fc770278e668263dbfed99766233bdfbf86376aab72ce8a2153af5632f1fb287406c76c89ed168d7597183cf7491c7ac45d64d58d73702b08c5a77cfe3a7364e36e40b84fdaee1bf3f83620e855ad6a8198c693a75cc68a541c4f900433d522eb853b2a1d2c7e1156e4b976272b7a7a6ad729ea4e233c881f26dbaa799b774fcb4b9efe681ed0f6f6fc73a2a423d5a96ddab85f4bc156c123bfede9943e7e5e9d2c6d5572679eb1c9522c454e96790988c255edc412dd31f536ccd5bacf7e39192b7e6b9b93879b90a4a4e5c666ee74aff2cf7c3ed2ca5943c3771a93c8e091f54a961ced82734b66445c9c477727162c05cbef42a69f113e8277b9ab60afc2a8029bd3d1157a5cb160f45f08cf5144cd1123ff68cdd572aa9a676adbd76edb70f8d2a44161637084bd7968253632555ad95c27dacc0a55ba7c55cb08e32e2ac64cb031f5b5af13b4fb75b645205c9b926fb90e518c35cb552703fac5125226a12343d568ae68866bc50708b7fc3d7a19d4f3ba1111248b75704fd71357e297d94c032981778089bae2ff7b70418ae022111ac9820894588c98b8363afa0b9ab5f583c5a8769563b37f17ea980c9bbf46dfbd5f07836f86fbc23533341bc2379dc350620762bba575e66e66a2471e07a96756d26492863f1f7c6bc1e84484996bfc5d1b9306fa956538881a118199037ecea910a6c734727b6c3308013cc543b6d0e478abe79818cfb7c9946bc443bb32835449051d09bd828733f12e47a8398ce8abbb204ce5296aa315305a81e2ef18702adbf8c750cadfe80c4c21fad56714963c7e007572eb79fa25820027e924fba5a4c5afd2898a07710185c8eed009c6b37de5a392d0ccb087a809e231ee76e7ac975292920e4e44d076ecf9552eaf335abfe3aa472c31ff88233b17850bdedb8ae0ffae54761d6b0be6ac1de844b36d73f4160e9a83c581b0dbc3bf8c5d3881111dcac9e74d359e4d44996c334fd2a7b9b081dad69d240cd8537bd421390cb06a6a5d4cb713c09fd46de360a0e1b367ab44e0f046124399c59d54f0c864298d7efd9171550adaf50d7eb5a1a41285434b3dd4976af7412d86f0927035b4ca8a3ea6bac9ebf6e5b86dc6974c95b80e383c22086d71f351086f97169a5132aa9c01b4c87686cf5f66e0073937eeedb01dcf317961991cdc697acca3cb2af9cb9cfb0c66e8be6bde0a73bbd408a99c4b7d9475d190b54cad34f2f9ef67b3a05dd411ea5d5259ca7437dcabe2bf44ef090ca0d86bb64a8de349bba7b9359a5ee701d170d15437884be3116aad28d73b90f19243ffe47beebdbd914d046b699a7c334334db82b065941cffbf7f6e26fa51ca66ba6aad1a69cfdd1119669ceb8352b4eb975fa79d8bf6c9d26f9153d5a4524248907787814d2cf175ce0e2d6a3bb1d039d313bb5b83f0fc6f7e6ca76f84f57a97727f58e37218b8da12147a976cf87b9b65f012d57eb0e6d84a06a29eb79e3730ac279d16ea650bff2266866f58c0a2a3ad1855792d86722a1a8838f74f2b3fbd173d38862551eb28a55ba56c40573fc8221cd2cf9bd941e18e54f1866e638f9078a63ef46bc94eaaabf44bce811383beadae5ad446ca7c630689847129d4e942c9982eb2a72a9de7efdac6fa424fa83d95c54c4d9ee90b49fd3b79836739f779e284a33a06a03580e106459d865798006d1e79e758887c8d588f8cae16092196430d6bbf647469e0f61bb9a3ea6b0d04bd7ab07746b569e4de9310e92dde438a68191ac00a094614b7447ff6ae609c0862d58b72fd4653e49a1478689e9a5aa8feb97ea4c64e6cc70579de84acc7aa2e70498e5c1ab8cbe57914b73f43792140b26bcafcdd18e3db1dfc18d9fd799388b24ea76954128601f668ffffc0f4bf19dc1f4fb25b92a94aa54b282bed89650d87f371e3a6368c02637d5d921a5a7fde4543a5c28269ee1664672ce150ad2ba1d43847873092e41a3379fa2137f3e658e433dd0890d62ad7f48e6ae1e0e31489b1fa3051fb81e478958817af361e14c18ae72ac99b37fffaafbfe56a1c84f5ec4d61fea4c33708b63781449ce3cf8f439bd72aa6fd7caaa9d4a4fb7cc629c11e663d7ae3d8b9896a1d3d96285039c098a1706d85fe475be9cd29c8397bac789ca268842a24990a4695da088382123eb2b2c80b520a110a81dcb1f238cb5232228680b5dad4b0f367dd86b2064f650f8f3113403429ff1af316ca40b1eecd41b50e3a765492d8cba652b4f5273af3bd50b07360e8c6afcb58d457971ab556fb4291c832289bfd8eae9af6d4ed8e6a1bfce1af543069a2db841ca4cd0e3a49e90505ba4573c78bd66cb1cfd3f0b07f23588ac7d0cecee054b48bd2242a95dd1b8c52436bb713f32fa63d2bc67836fe11d74c448dbe930662c623ad1309d4b5f101183f31f446fecf2ccdfaa3185546ccee31af34dc70c44e97a567d9630dac59fc548f857d29a8cca54eb241923981ec863e2e60649c611d369543f789ede6e87e8525e41ce6bb2365ee6c18c7a6848bceb31950599de1e5d03662c99484160fa70ce3363fbbe1d5e6dd2322a406a7cb86b6763cc63a705184c33ee1d63a8bc7dfb01103852ba38b1aca40a68c1468337918b7b4ddb70dc745c30f153ab36af93450fcc782d55364d64c3cd77bfe1ff04a1b760192b9a15c6b6906bd218beb9f361f08bb9b52848753efd8c6871e0b29184d3439c563ceb26d02a1cbf185cae76636c01b21971eeb8c9e5008ff86f2a2998342ad83c93410b7ed085339e711221450c487964593bd7d09d020d3c71a2fc9898eec31d96b26d8cc239ec6662469c1747a437d78282decd87e5c1eb3358cd8b887c9f07aa312750b6e2fd01e67ff84a7e42caaa1b6991a892e7e02cee8f1a01443c5807df5dac0f4cce8dc15f3b01d1dc2618d3559bdc65ace64246fb01fe1f21b139e44b4b2f099a5c046b8affabe8d9bdc143eb2d662751f989a1d78f61f0f18fd6615323cfcca38ffc15ad3024eccbe7a749fb7264855e48e98ae5deefc556609f5823def28959e6c60eca16df22b2d79c69e903305542941d4461d52bf84e499ed901c929789ade5d0d8089a1a6adc0eb18396988b42d81969ea9f8376482b7be1d5f717d95d5fb0817304f537097a2abcbdde4e80018315ea4df10153f656785a0b9f300000000000000000000000000000000080d13181f2c323b02926fbb379f8a46c09b74cec9e6f02ead1e047e888fadbe0f1b9077408b64ef80f86ed022885104b2950fcad9fc062324531008a7046fceb28ee006ec3902d9a0199405b38878611b2526e12f82cce1afab520c691ecfe1a9f2be4b4bc254910ca8a95be054ab80780a80b5a8d9e66fe7163c4a7f78a67e841e860aeffba70e0b7464fb2b4a70ac34edc2b96d89ca99e845f48baf2c865de989c1f0cb3ddd73ac6b2f72d67e8eb0444d201041aeaeba54b87061259374ad55c0d20601e09d1017747067d95f023a63f51d432d836ee184ac1b212a169f52c733b017abe893a12b2d413cbe38dae1d30a7239a9c6db5aec9c84e7e99069516aa134d5e7b5bfbdf0b070994a84bebcd92cfd3f0ca3234c8048133aba961f07d2f75537fe881230bc90f075c4cce3a6d106351e7e4354b41bd8f1787a30836b852749fff8ff6fbb8f8c98a78b71dba52fc2f2ec9545fcf22b692663ef551d40e9950c4288bf2dfd265f4c57c756d4098ac2cc0520fb739d18b51a47cdd3be2d7b25f1b08eb274dceea941844605592b57ea37f47d84c871f9101e8ebc6e8983700ea4e7355854ea85041e29a8b9e52a025087cd84b21b9d139c9f121d1f6ecd4a94721931f2bd95ecedd5faa9296a4dcf6cd1d357a57ed7554cc0958d829ab380695cbaa8737e4e19c8465164a4b0923cbf93c8b8eab652062bfde202f6a07f9fa359dfe463ad606a6672616d655f7479706567676f6f64627965","frame_type":"goodbye","seq":4}],"peer_key":"929523790864676af4784811f207c1251f2f51cdc0f7396ce539a680cb4739a48e46df873e936cc2cede9aa312e0020557dd1b11d8e29cc6c32c98df385e37144b7a2f3e94ff2a047f42f89a3df5bb20386ad5361aea2d200fcf5f4bb2ecfe05fab6cf8b8e900bbb5fc4c8b18805c731d48def82b02768f5b289e436e6a0fd68b7de657300ee9ee729031ec7bcd2e2f39836242584621d44f4690dde962f0ad3b652b65712b8bc22203306fc1d585fd4795d019fa2dbb252d912b5d716323a790fabb48658e8fb6351b407caf2b63f015eb27e46762445c6025920aa38ff564d34adb2f82fca088d05812f36850e1c5f92c86c9c262a380f4482adb61d2749dc1d25b360a1badd6fd486b01946eccd106ac1e73ea39f30372644c237be40bd71c866ce32e5266d9df9ce3fe49eb457a8a2c5408a8ad54772749529559a6c58dbf14439defb34cbe7a5cbcd84e7340fc4fac1dfba90c18b5cd86dc0a74a3a0e7b58d5ca08c4e5df5a2709bd80130e83aa46ef671957321255e88c1d1712d39baa079d6b36c7c03de2fcfa3dcaafe297ab5a809de6968812aaa000a9d4ef3ff0ac7814b7e06dc64cac5afc89678137b78d9aedff27705064fb1de695f8a1d5cc4e7f496082881b8d6f61f8facba731d50a32f8fe21abd6e3decd0829d142873d4eb9794df9a928e45b3df441edd6e39fca8c1d3c1ba1badb0f1eb62a701e296cb042633f007e0cfe000cca3f8ba05880837bd3840e8c993eca3527242b90763dcdd1d96503a6a6b23dd55debb290047052eeb8b0c2fc1c86e2a00429bc8209d5378ab099830e697577bc54c1f4c9d62dd40c5edcea3f1c113b8bd7e6a3ea94acb4e7c2fa6306ee5b8801247d73b59221f624835843bfe5ea713e18f58f636ccaad8e546b36964fad9cf4b9b657355772a360fb685f7b286d0beb3de9deee75f1b85375342dac6330ef8742564cc7f792de3fd50f0c865048e77e73dfd241f2dec601902466d34cc8ee37786269f055dfcc3902283b94e79057c41c018291521fc213cb5bf3d428ecd7ac5ed5c705f35ae26e845e95801246f32b14d57fe3ec2b828aadb4e9d70363c66ccc6b0b5a8fcd868b8202158a55c4e0a0d2a06e8a0344cb9266487120412c4dd5f40f3d609330be6208bce58054b48013daa112c1f27489cb4945e1ab31f6942d83b9008d5d5520db5bd93df746951ea88ba40402bd96c474a4804946d100b7978df47a15524f84e2697fe34a6223dfca75909ccbe9a65fa7e44340d498db8a67c791a3d69a4095b699ce94404d12066cf0096205f623a873655c0bb008b04c8888ba739a9848ed92babaf192e198d6fb1a2da732f0db06edc6202c75eab9c39876862a02f702a92c797f363b561f4681b3a2554152e8b1bb0f8cbda80472bcf07d4c02b4fb46fd35d698ca0641f2776e778f4dcd0f19656e40664433133f7d0ad5421718b7a8ec5fca8cdf73319e57d55896805a5c4afd4453f62631347e700c366613f43ade99835ff3e3c0abbf2ad7f35e35fd6a501f021b544c4c95a88f056810721441627ec37c3e86c57f993d71afb4b4d52d8cbeb38cb8d16b44a6594e02da5e94bc8b505a179c7a9c84d177b2fc7d8bb076b74203c458e6d58ac34225c78a65ae40f8bbe3aa45285cb09bf203ef0d0f62980283d522872631cc52d5d49bce60be560d16329356e0ca2b323a2763a9d7c62514d1e87e165df380fb61eea54fe88d633a56d95dd2855d45eca70eb6e84c0575ee96a2630fb21eccfc6984b1714622efae3a0c5aab89563e23a8bddc4fd874abdf826be8b4c43bc9599c9ae2275a38edde2fbb7c0d87b22b0853a9254b790e1d16c687d0de6b31567d927e096487febdd27cebdfc4b3f630eaf14f16e8d49b6a6096dd0d6423a5f6dfe9bb5505d25477b2d4f05d798eea629e5bb7ab1c8a6b453dd524765562855db7d54b3ab0c13a3fe8322a64a793b875987dd64a281f74b761f119758b675b0f52d259fceb43cf41e238a155d435a419cc856a9e9a63250f3731037f559ffb742439db98bcbd629361a3e69df6b4e9f44a7e0b3a5875874010c37a52b5231492daa238b21a54fb0568b5fa1ce30503b38568ed2f31993ef913ad0e109fac63d4865d7b3a479307a2a4ff6a7ea1273c3273ea93bffab4a4bb92b188656973a111fedb2b3544c2ad111f892f776d824ba260b94ea68fd6e225db254e4374c9c411e19f347188a86e718398dc23d1b6412692aee81c219b4ce0a2a30acb759cd5c7e80d556a8f8b9ca37f709dcf3fefb06b173e43f00e4b299f6562e2f65a77db6ea26760d4bfa04530c250cc6644e5367d4ec7b999a3908ca1140c9db884b6cc7d9ea6cc7e7d94570c1232c7a8f533d44946e57b9c87f59b47cb7a80026ff485f4add787b02e9e3e51d5381fd295096a21c530003f3ed0c04c0e274b7c18cd3b59c9beb954ecf0c4880234ce77b06c114c340aab9a74421b6f7dabde2a7d389d689da5962eed43e2b48191432398922f12efdfc6fbc841bc27524500e96eeefaa6d57c940f2f4f25ed9b43a26d652d467de8143be161daa5b81d0571d14069fb02e8e28fc02ffa3f44dd7eb8d736a53177b3de12e98142bf31eb468c6e1b25b1a69b06c1242bc021cf8c21209fb84d3f331e7739b2a5ea399eb46040f7030fbc62c545df979fa53283e5ea5579dae5ea7d74e7bcef609e07985646f313f4ee2fe41930daaa6a740baea1ac93c06237a8f46519a64c1d90c072412a0c672f324f50812c40a74b0d59caf906197a6db2914c3607ac646992aa257c00909f0b95b2ee2c79e7535e67309ad91492ad5248e240577a9d281e9d4dc724430fe3d5e59626e582d038047cd19747ba541855bdc72938659c275406ada774da099279621788f0fdf74cde0cc69555a41e360989488c41616f0d44b780208973609949a5859f81e46ec8ba3b5a002de6e35ceb79ec45b7bb85af125e33df26aa448339fe185a2484df48e8be0d0ea847005cd5c17c08863ed3944d023d1bf51c61e9c5d9c461719461451a47f6fa5be1fd28e47e6033fab1fb14ba0253ff9dec5e148192af07c476e14da460cc6b5cae2d397e56a389a84b09fcccfe642bcf0c840697058ec61fa2953522a32a518fb566d679b0fcdfce5111a327a4ed855eec1d760e276ed26f2420f55777c9f7274fd214fc473e636c5e802dda4dbd10a3cd08f14fa81364c6c9f0c492802fa0a119c2728a0c7457601a2a5c3c387804bd43871f1f310833620dfb74103b274248e45fd943bd628453e6eb9b873209d2487c791d40b3d53575c93a488f4bb4764f7e3430c31a9eceaf123f43664f56393defa423a953016274383b6e6acb05159fbdcd5945b74678495f09e48972efbc32bedc6bdea08d81b32004bc168b9f24bc871affe9137c755dfada727b4672f21ac05b7f9e744e478873da4d57115e0f6034d0ad2e3654f2f30e67811aa8b6e11ee73d33a6b7adfa97cad62b1c3a69b30a8202c6c0339dff586bed0f068b0dc69ca4ae0fcafa17038551d5fdcbca8db7d4533acbacd09abf2733d05c1a2b8e7caadff019ffc270f159983f958eea09550787ce7dae5baed13fd151feaaadc4e5c61f29247d424a68d20d57e253715402ad4f61059fbcd43138dab76f4cfaaef7f16a3082020a0282020100bbc7348ce5f9ee7e253a67817f215b1ad505c24912b4c3b8147c682bba8af8ed56c1218a252366995047277187744420de9a2f5f635d7a885895ac1658e8d82d8fac0c77fd4e0f56017c40f8f26b8d43045e2504c95086ea8f74c80c20ef54eab5585debb600dbcaf45ec7da92378d956155f1d61cad9369e3e25bfbc157c59f0a90eb006baf9e1323d9161c44351c41bdf5fa3515f5f6cb04d7d3f9e95a6ff667ba29b59bc6be6f3f0e81cf97c0911c1651d149ed4a3d46079f05032e909030739671d06b1e2413a35fa2dcdf4e379614240326e55c526df62afc74131df1bbea861e53c44c82cd5c541301623f4dbb26c01719ef036097e40e5fb5eb2eb847015a0b38b3f9a189ba789894d3f47a315674c2e64efaa00d0f3c7a6d2321ce47a26e194f0477386bc519c0fdf849dd72fe30d93b985cec8139939231d050e4400e295156850df447bb6a31167bed0ae8e48780b2c25d020a0156bae2a4c48bca613861ec4e771ef1fd2f810dccdf85026a83769616e0ddba20accc1fb43aafc426fe900927921c8a0d6a88de478b9146823d384d5471ecc81c1e61215339d46ae67d25ff7e8155ff19b2c2283d6bc12a3f924e2f99a943330e4532a93c6444b70855268be3765719e9f270ec6227684846fd517fc802d7f0653973d90d2274a5688b93410dc42fcc4b6d98f00a65b7d949593c26d3c7d4a29276018d5f6595090203010001","profile":"pq_hybrid"},{"connection":"eabdc9f49a7bfc3d0e335b0d9ea03cd486af2bf4328619c683ef24baa77b29a16ed1068fd27fa8a9440f85dda6948e9b","frames":[{"bytes":"000000b0a9657265616c6df66763616c6c5f6964f66776657273696f6e02686672616d655f69645001a0d10e857a757da38327905583e60e6a6672616d655f74797065696164766572746973656a73656e745f61745f6d731b000001a0d10e857a6c6361706162696c6974696573006c736f757263655f726f757465f66d6164766572746973656d656e74582761207369676e65642070726f6365647572655f6164766572746973656d656e74207265636f7264","frame_type":"advertise","seq":0},{"bytes":"000000a2a9657265616c6df66763616c6c5f6964f66776657273696f6e02686672616d655f69645001a0d10e857a7e36aefea4f1ea5457106a6672616d655f747970656b756e6164766572746973656a73656e745f61745f6d731b000001a0d10e857a6a7769746864726177616c581a61207369676e6564207769746864726177616c207265636f72646c6361706162696c6974696573006c736f757263655f726f757465f6","frame_type":"unadvertise","seq":1},{"bytes":"0000010aab657265616c6d5820030303030303030303030303030303030303030303030303030303030303030365746f7069635832696f2e6d6163756c612f6d636c2d6e6577732f6e6577732f776972652f6e6577735f6974656d5f7265706f727465645f76316763616c6c5f6964f6676f7074696f6e73a06776657273696f6e02686672616d655f69645001a0d10e857a76fbbdf9f880c2b0942a6a6672616d655f74797065697375627363726962656a73656e745f61745f6d731b000001a0d10e857a6a73756273637269626572582001010101010101010101010101010101010101010101010101010101010101016c6361706162696c6974696573006c736f757263655f726f757465f6","frame_type":"subscribe","seq":2},{"bytes":"00000103aa657265616c6d5820030303030303030303030303030303030303030303030303030303030303030365746f7069635832696f2e6d6163756c612f6d636c2d6e6577732f6e6577732f776972652f6e6577735f6974656d5f7265706f727465645f76316763616c6c5f6964f66776657273696f6e02686672616d655f69645001a0d10e857a70dd9a9e0d0ed59d6dd86a6672616d655f747970656b756e7375627363726962656a73656e745f61745f6d731b000001a0d10e857a6a73756273637269626572582001010101010101010101010101010101010101010101010101010101010101016c6361706162696c6974696573006c736f757263655f726f757465f6","frame_type":"unsubscribe","seq":3},{"bytes":"00000094aa657265616c6df66664657461696c47636c6f73696e6766726561736f6e666e6f726d616c6763616c6c5f6964f66776657273696f6e02686672616d655f69645001a0d10e857a78ff82c791369ef271e66a6672616d655f7479706567676f6f646279656a73656e745f61745f6d731b000001a0d10e857a6c6361706162696c6974696573006c736f757263655f726f757465f6","frame_type":"goodbye","seq":4}],"peer_key":"54fb24c267da083be974f6918367503efe43ebb028bcb59741bbc194db02131aabc3a644a9bd1f005f938422e4501b98e70c0d072a7d08c8415cdb496a977366051a783e4e2cc58b3ce19d1fa0dd45994c1796fe37d6f71189317f77ae77357e92051e758e199d32fe0fc6e1fd60b354947209663673805aab2c9f43ee9db253159e5cb02466e35c0c835ce8062a642a816d4e72442bf13b25dd236901f6b69ef8aa094ca7c5455c7e0d086d23551c217c839bee1fc90dc5080326064038e5444f5cf748792540743f7e3a64e44a0624e0a2cbf70301416ce3c5ae20f37b57b62119209840984a5996ebe10f4fcbe0207e9a88f3b3c450c394bc6dad93d37f8c1c965ab2c31f9c80649722a4b5b68e20ae5d14fe8f181efe430f40527fd43fef0a1318a8f24bbb53243340f45e98a91498ed30dce7a9caef197462083ff1a5c56acacc53c7f7bb55dfb4dffae34a512b3038e336d63814b1517d91afd2bb06ace992b6125c35b3e28441dcfeb1f393f45b8bb37eb17e0369e9ba9c5d0db054c3bbfcb59130830d191ea3b70eee3e966b0b9e3bd7cc110111c9c39746ef5326722bf9d20106adeb4b7c2553d627888d4213ef105da3f35fbac1cd33a73ce63bf046bde66508240a2a6216366c0ce112273abb7631fe872ba0c13a4fc37bd4f114ae37c82f01629e0938fb5cebbe801a0680f029699ec47fce1b71c35f40c3d0f547864bb7e1f8f8c964c533cdd504b7fb853d828dfcbfe1e826555061a43ea181e402a7c9c628d7dfee81ec1961ef2af220d406b80dd19e2f5d11117f31e41aefb13af34ca6a1df23619b0ca949b669604cb3de3fef3f42525d3fca10a857b4409234c5f54b2f2ba1ff86f208f2f38ae912f8d311749c902d197f31ff13ca1db8fb514286fc3815782aac1c6f6333c5e85f202d31feb10242a27a1cf510dd65f8fe6740d9d5decc218d9cde7942ece229ac2ccd0527dc581536afd3d47f2c1aa9443ea4822865c6a49e5030ffaa75e306a9337fde5901599bb6604abf05b8acbd836c89e78573a5f64ad986eafc89e9cc5dbdf80db1dea862c0044c1e92b7e19f1dcc01f08656b2d5c94ceabbdffd2abef5c89b80f644afcd353d3065ed0877d519bd5a93a598cb86c9d0c0ab8db469850fd0b253228c368b328bf63072d67171edf8c3b0b55c1521c50add912c05d75fded778743ed1017ebfb7689879a4f51165e08bdf47da6ecafe7531d9f021a67492ebff6560468deb50621f282c5496778e0d96d99133b31a41db897531bd025bca7e0cff0fe6b2ec02eb185ae9afc17f0471e2c0980f29e9b361fb8d3b97991899417440636bc0b2dbd99566e6c75ab8410ebcedfc6fc9c3c1a927a3be37eecc9435bfcd064e2584b757244370d49b9f5d4922c9038d6bd2af0467f1afde487b235dc42d014b4aacc9119121428a3ef692ffb749a15f1799d3cdf4b15374b36aebd74e730348cb1fca032b05dfd12b61c1b029a19f00a86896be0bb05254b1152819aeda59224aeb6e312e1a4c7a557b744e2f1882dc54e726fcbd432bfcab4910f17bc35f00b4bd77d91c5bae5ef6816016ae839cb153a920e2ce32f51022bcacfa382bf57555d7a5403e95b22323268c48c1f033b30ccc4c176dcdde76c32a94afbc43acab6ece51341e467025d8b5ed0629bbabcbc406f1917a2a852c574a528480597a73cbdb83b576c4d31469508d371b6f7acd24498cb6079bbc1234a2c7558f3104fb605319c00c1c9a010a89676c9be03b6d917503860dde89c0c2e38046b60114d75a02b6862311c3f773ca9249d0c1dbd77d863c33c0346203c5a627ccdc597011c812741d742a41f0f5acec586aed6093bbfdf66f998ee7b1d004d7d025acfcd7c2455e64d38239e0e05f8f8b62e9084348b68c3eaa0847c36d15e0baf88e5d29c42596203077c89974472bdde11904afeae960251d20b18f6af2b32b037036278b686779e09143bf88bea0624b458f8ef57c3e7f30c31ec88092dce8e777eff21c6ac98935c4a776f3a804ac84e6dd4e3599931afd7ee8d3f9465107804abe17b0c29877ed537ba993e63be6609debbf8e6f8151181d250dc019cfbbd26c8c2a58cdc0b202d63e1a00c247a87d0f84963fa0a81bef7e1d1c74b270ec6d38e5b4aaf2359ccf3f02dfb2d213c2806b1d54766385de46e28dc21b3b5b0ea2c7acbf2e5ec5435a4ac91f23acfaa7a43c4735f1fb3c2a7684794bf2405559633e81258bf08e7bedcce9460fc4af18794ad3dc17b185c9debfc873be7679f0f694906a70e1721233ca9d4c7bf2dbcf11034cbcc0bcbfb6dee1da17262c364298b74d03db09342fcbc0177963c0347872ab3d9d9092d73d00e8fc5596f7bd6aa16d0687837d100e766038d78e783ad88596c55775a543f18cdd57a8156371ed3b3d2aac77f12a6d99a9e632ea95bdd6817a9d6af27a5dcae7a776aa8a606354babb21c9abe7580164dd5562db2c9e296b907cd655811addd824d7d0a76f0f98441f7be89524b8373b4aed34b6158eb6e8850f5b3ab79dbf5ef267686420244014e5386e5df18537646a811039d8116c6295ffbedca63cbd5ad2cef870a32a2471a437825d8893bdac7156bdddf0c93a01a92a06f825fd694b8cd82dbddd0bc6bb36c56e48348de524d0bb879c549193ca009577ca3375d644e93f0c25c248abc024e21a93d6aec316de69821dc31a2c9528319f9d6f2e83f2da4183c5749e28fe0748fff8d1f7244d0a0cc38c991c54d37e1dfae31d603ada71efa47f3843237bcb775622aa44799d70eb49649545ccec9625a3614723d658ff378cdf797206773e96dc41903f65a039a7fe0d204eb0bdc5d32ebfa1cdd45a433bec70bf889152c8bdded5fd2d15e1a4c8f1b2f673b8269fd7d4be17d4244990c886eaf74db6c58ec61b89a055b1e815be684eb1e53b09ac32718f9eac054d9626c6c626e8a37d5849e1051b36e02f404fde2509c58e92bb0eb23c33dcf6805ff6b24ec81adfa8194154eae8da537c0d412ca72750897db8fd705a1e9c6ff86b04be780a9a3b91a87ff87a1fc1abff186732dd4e761ad7fd04a70af90ab1051b03a225405fdcd0812b4bd0b462482590b335bcd4ce758362df00593230d9f4cb73b5d34a3be47fe44a793ed1ef76b32a80dfd5cb4055203eea44d4f26200dec4850bf2d0e17e24da9a054a0c582d3a24e3896df6d52f0405a8c39ac748a6db68d1f6a089e127992d7e939f55af092311a8595db77ca310062d92329f6759c198541dacd586f4423328e2fa04a0b5befc3fbf10da008c6df1f614d2d2cb8d7e29d01d1765f6c2a5b0c2d4f433da1e48705c6a8dae5f6eee0981437284bab7f7376c52c87983af233a672269dd94a103437354bae54d7a40538e6eff1e62cbb2426648d89e46fcddfbd740a44e39abf8fcc65cf8bfcbda6cb4b2a283d8debf7b62c4070757d7ec53585b13ba1cdf843f17dbb8061cb80a44130f244ddc7efc238b2203a075e2fc9cdfb92762405ea442eba33a7c5a9cfc7abed48086c9a6666c68d1bba2484a5967a95f6addcb5ec663324015523f1cc96873b2e299c203112ce4eb7cb170e30ac1a7cb1cc241fff28d87594c8918b7f08b83f3d52efc88253e57790c093150b8beb28504264d872613a5328f4dce","profile":"pq_pure"}],"generator":"macula_frame at macula v12.1.0, OTP 28"} \ No newline at end of file diff --git a/tests/vectors/handshake/erlang_handshake.json b/tests/vectors/handshake/erlang_handshake.json new file mode 100644 index 0000000..416da77 --- /dev/null +++ b/tests/vectors/handshake/erlang_handshake.json @@ -0,0 +1,21 @@ +{ + "entries": [ + { + "erlang_challenge": "a7656e6f6e636558207bcfb17e9cf8af39418642d6f1dbcc576f3c9934756b79d23fcf9215d64efbce6770726f66696c656770715f707572656776657273696f6e046a6672616d655f74797065696368616c6c656e67656a746c735f737461747573a26374627358bda6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f696458208820d319fe9a91017f55a37b38c62e0284cc95f47ddf8257ba7123ed0dc43ad2677369675f616c67694d4c2d4453412d3837696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f6861736858307fc7561481726bb194bc07538f86d3761030f71ff0c2efa68d8d161f82f102146412510c118bf9df75fab5d2180b5a53697369676e6174757265591213b99ab24c4b19828550cd4650b12429d629fc1fdb9cf973964262e8bdeed793326cba653312d47c537786dc022f28ea88396c1213db00b2363f5c2d8a8f78ebba0470b151f8c4a275a3e7153bfbe5eb73e95bf4ef25dbb60824f6ef95baabb1edd938cf7f9064d92b2b39c680646a67cc4d00d361e4d28715a98fda4588e4a4f5116065a39b5538e9a1f2c7eb687534abf4fe4cb8fa9d775b309fe3c9d0748d5b4759539270964a12690229453893e2128ebe746ef163e9f198ad2bbb36e40e304c2cd5feb64d75efae6bc5a419b05033c2f49bd7cf604c5200952ba2f0947c70ec146fbcd7002b2f8be7b67a05bfe716461fb1d58417285b6530c7c140fdf41252ba496fe732e303e49c9fa92ef8675bc813d42d413634f6b11ef5ff1c8a0e3ca2ec1d16bc57d5f99e9157c3a0587e47e3b116dac6c21c733090960dd53918050e71147b56d52e7764109e33ac3203f208fc4b489d37d73c7aaeac0f8ca72c4450539deeec57a0ddbf34a3c3436a89fa80cd5b8915541fbadfab64b1d7a48f640dfd17b165587fa3f3b0f379d5bf0fd5303a86bb136a96b7b918cedf0973db04bf12cc0edd930f2aee906daadcebb57d463badc61209eb2905aeab77dfc66eed5e3000bfdf1ea707464aeb7c01a02acda30fa476412c3547569a814e02333ca44c33047ef43d13fbe3749f96da5e3d27c77632d90935952d27c91041fe72cf55b4a0cb1fce3d7c7d3cf0022141b1179c44d7a3e9b99007f247cba8d8c92522337a4ef9094a3c0fdc54a7c7dca0d48f148200785e6805cdb748f0561f3a075ab599b48167ee5878319d2c49187fb8f2f8e57058605ea601dc956b51a7e2080abb9c15b2118c7ef0d27467050838b0a57de1a98eaec5988f252e04ad72b2644e8074e91f75b542a8db3a8d0fc96eafd933692b4db7d96d382b87d7c1cd1717fc4988b2fedb9552968293c5f8a41893f34933526a7b2d96854485c3729c68cff0fa47dc315d7d0db7beedb0dccf1db2608880cc732b955a3ed178d2de879ab97fae983582a1ae92a322696b104c9abc74aecdd0aa63a33f0a4c0354417df3b25ade91adc395bf8daf6a6a42e60d122eab7b168da68b416036045b841e122d20a8e796246754990a34e930de7ef8e41d54ca9b4e08fe3bd19c5327231f4b26def9c43485c9505f3ba22a909a556939b688ca8745447e10dbbaddde177f4394a1e1bba77201dc1788ec27f0988fad4e7a7de9fb304a5ac1305f2e98007c90aa99d31327a499f6ed2af8b809fd1870b72925794e45ba10c57fa455ca0eab60811eb7de40a99be1a3126b11ba9e3d73e800044e8c903f2a8af562caf245793382bcfda24bff9d67e9d7a6f35f12c43d9c4a9139e011f48a95870c472707033679fb6af8abcdbd854030835bb2def9016b93e98b5a4703c6da10bb11112e332852e5a138b1d099c23ad48c597d2e8229268ef87c4487bd3f71df7a37e9344be4a487f133048100d579db554f52e6acd1c962ea933ce4b3745ac97c2166c08e797e9b980cb52c904ca6c1d5b77e96e397fe66940f04a4eaf8fe337b0586c8a374ec515dbb6318762f85c109459aaff35553824557cd0f90fbecd0414ece2dca005118e3bb64207ba02cfc0f7445d0bbff8c8c0f8a650f765e6fef7ac5d175cf0a43223ea9c6915e2435f5e6ad3cd4e823bcaed598f0dd1f5607699e366c001ac24f2f68177465e09e7ea301de53ffb00414443669c99c3def3085d5185b6d053c713786212adc9bb406fc53c591385b2f45ae44949fd0cd8264e567973ae5ca29e6fb013845af851c8bbeca3df016821bf94fb9b078d1f454e925495eecd2642c25eccf4a1ce7b06fc38451094e02fb87ac5a599abec0737934a67326bf797a9f1f94163392db4f5dbb3f8c1d5a545620b0292c049ae3027fa263308b702e8ff3929b28c9b6956ba55fcf30c3878fe638cee53599251f2cf2e45cac1d3c0e1a705439b3fc9fe84f460db442a69413ab89368869f171e111de2dc3214f7ee1bb250780d3ab2344dec178a107bfe8c1b18504c5dd3ca0af5c46da4ca28be35acb3274c991e2e70004c3cd5c757dc4dc4f9a53586b7ec5e56aa8035f793abc17b441a8b8ac6eebb59b0d7cfa6fbc54956c066106be9bcce26349608a73d9d80c0e441104c19af3bf03182545708ef6922e1b872cd4cf40a23b69be79fe3a0e3d4285c4506d7d7afed5032f57bbf7b8b83f0ed5ef5d0fbc63e85556d6d9d41bd36ae21060f53141c93627ad4fa56cefa95b8810c09f96c79faf5c95bdb3662aaedeaaac2be0a6ee033ab72ee0af8dc7685f83faf862e919b0f0da2d64c580dba7a97f5995dae3d439b6e45985818ba500d56c631ed4dc4436c3f31df444e2f774b4617020bc0302d0275fb2a2b2b3991a3fddaee025b7ce6c9b65aceb0b311532145c402f7d2af5dbc57777fde7cc02aff8665488f92febe53f487bcd3b473825b169a40c9775410255aad9d2631ab71dc62690fce7ab0e88a0a1620e1601075ee5bda824032cd17351e19a827aeff2969950b71ef7977c361c935df2e740aeddfe952b86c384d418ce31b3c6d6ad6ccdf8a6aa0137cef5e4eb8c10c6d76e2d0591d283f7c5f062d07a416d3cd782c64d58a6f32c98a8268df7670cf8f36df4535deaaae7508ea180955c316004fc7059692cab5d781bfaee14aa03b827f548d56e36a18c0a28079e3c05f2c1f6fa4ea93df3c6797d1087ddd2ef1e8cfc332046e0542fab8479461ce4b0b7001868ad24eb2142c5f3a60513258aba5aae39a475c4fba842770dbec3b64747d4501953691ff7829840afda292940df35a42ea53e1a0f3e5441a09373804e78e43082667064f5336b8eb388bfd60942ee450b4c7c3d159063fdb1aba6a453c1d8223aa9b7bf02393058d7cf9b21c322e8801e7db3336e9dfe1c69bc7dfbe99f8b9f7f04f6f4d6f2bd5a2d98d608f318a19553cbfb0c7fbf4d63241454bd2ef1492c8c2422705c0428020b0e34b04bc6c107ab5c22383d229700d024809feb4747e9fbdb6d08a24fd3adfae454495585f3705c64980fcc90844f4f1516793366d2067357f73b6822d2d765e153ebf11832c33a4936c6b4826c2a72be056a7c78be1dae8637527f9725d3911408f3f5473d6f0d2d8435c1c44c9e8797099b6ad68b772b6cb45ee06dd676bbee177b26258febfa6d1d2af77cdf51bc10bd4026ba67fae39fda8900c0a74c0ac4e93dfd1668fb3e9d2816d2a4694479452ae55d3ee34a360cea9a2f9ff9d3fcdc6d59504411968e3136ead9e9c387d505c38c6fea6e4641f2046e727d5cb366a66c003fc341fecfa0fedf4f09b0d996c12b9bbd705db9d4744646d689f3ff878cedb1af01e62bc4ef21df45356abfb464678d2d81568c4eed94e77389b8e092427f684201dbdd98cd88359d56a8e5019227d67c1e24072b156aa1c23b4b355b8fa3eda525d5b5af5bc1ddee07911c0d4d1e74551373eac6e45063f644f10d1aa58ce454d148b0bf99d5cafa46a5e72fb92ecc313934174f565b70232d7f3a18b77717ece277f06e55a6cb122aae4d1b5b62fbe9b56d0114feba3a35e6689d84aede2b9f6b6de202b3951c9fdf7a6c5ce5470062de6b5148e063e7c582448d8c5a76daf03d01fb232e377f4a4c32d2e16a9cf3d57bd79d5b492f542156010a8d027c19636bd090f376198c418537752e31920825f71ef8ea74af089515c10b6055423a385e9f3995d8be4991fd673c78b77b42b01eeae5ff75ab7845c612a044c60343b8256e463c5b77edff13b7709528f108e64480f6300568360717cbd11adf76331a84e6a87248220ad89d91fb7c56533554422f01029390c789fed7f9cae2b10b1bf058a5c7f87ce9a0535bd3a234ee3d5332825f146181bb4b98a0f9ef4736562d29ffaeaa826b8cfae39fbd99968e074f0026c6214fdbafb8b3b82b80f5cf7ffca2a2ba91cec6b74c990b0d663e2a3a649035f9482be3b69e904592344dcce9b80f9d2a4aac350d648d37fe52dcf7e5a1f6f6b50ec15e349aaad9eccbb45707afff6ad542681a417622c3b7dedb2b286a2a960a176f8b8b4bb5661399ba1b439e7211a552a80a6237f743ff72a4e380378e9347223ab5cf2806a70f9df3613c7afa8547a20bc6aef4126c3dfbd2a23d4d72ae73b2efa54245ae8e41bf42ae824c8c3f60c6c74820617d43b5b696a00646edb1753fc6e58687c12e568724ca0596e0bc4e410e4602a522a4caec97e31cb79b18a3d23952c0eb8bb52a6196ab86d5f4fc77cd41528b1dee0e8939d5fbfed682bd9ba999af8e55a9f16b58752d723b5a12644ad24eea24bb5c59ae6d87a26db41d3aad22192f031a8ed26337c706b70eb3b09596a928c19547a6378325d4339db05a9b8f1a6c69346a55a03c96e85d5f2f7dd57b7fcb31d3067c588303d04ca098c18e2d396a6beaf3944d81ab9c557d9d4235a0f7f6f76dd31aa0234c355dd3ebce9ccb5d0e81fc9c57026e704f3ceeb73a7df8a8749602114ae1e9fd00e230039a77454a3d224201752c8bb3c2d7827a9915e4332af51f184485138f7dee02293ac0e3996c60318ab1f53cef30bd28036b9f55a24016d38de0bd9f6de2ba0f79436f34d83b1194017adc2faae3864e79e951588bf8831bece0f4d6bdd46b8418bd1406980bee5f2e3fb5db64fbd195d7f4c428c9a51993fb81303eeea4be29982405da79232b195aaf2de5b61a640b101e86496df8c6bd70e665e0ddebe0028fc8589e35cc3fb1a46f33804a9839303edd334dedb924cc2e63316e0d6ef08f9f4db021ab44df53deeddc20f75bd421c99f2bfa2ca51499887b88b3cbe58f34e5e69b711c70090f3a5bb025da8926659db0999262c07789c574a05f77098b59d78522183584018dd87db2b187ba06bd3db67e2e114cde5ffe93bc80224c57afe0e6d1b57396805a16ef78b72d80b91d4f4b7b9ea7782d1f33b7875c798f439a56af07f0f25f931b1a8313a2cc6a9d6721b307a227a54c188df3f70114819d816e839861bcd8660311a703810695cea4c3c10c35625388ad436da4c9433b9845abf3c786c5b98051d99927b8f34a753414b76fe31d68f5f9aa6dbfd042add0db00aeae4877e460952fb348741ff9de4627689bffa9a7d42529c389f6d18eb1a5b60c7b83b27a99c2853aaeb77ffff1ca30c15af1f6469eda647a3f26c41b248bcdf85d4e161822d70755e2c731f94367d44d386a945195c215a953436ca71e852444a88422ab609083ad87be1b87955ececb5d199ddfac0d66ff7c705c010a8581dab0bd156afcf494ea812be5b0168427f4cfea41c8134e52160bfe247ac22175921a4733a33068fcb1bf2601a3c2cd50f74b3d9e84648b7d75e22af7917ec16d320e45fbb2101f41f96b34062b4c2faa41c9a86a0e0b0b3db7067999375603828fefdf984f6e7aac226d6e704f26965db95d2ae935f4a269ae6a49aaf3384b8e7df10eba640fc2343771165ecd9f45f146bbc43d898b55481532384871049e9668c0682610dc25dc5e6ad6a86907b33f6c7830ad9b6d693a0b4bc6f54bab7f09adf2340b6adaf5ca4c41fe1a5362104b9f93091d7ec8cf42a0c97a5fecc07f93cc98db24e606fb557698d7983bb3f1f1360f5fce0cba2059a38e70cca359d98d7a76be523e82fff6646d0887474ba6bacf8c13eb48a7542f0bde93b309e4d3b963daa41fea58399215b989abfbddb382039fcae1dbc04434acd7992b31351c5fb6b0392d59c5080c87dbd53ab5e45d42888a50fc38f7d3db69fefe984f524d6cd79c82893049c3d847bab9ae3d9576d1e167725d1c4ac3916153e28b7551889a2b269dccd2f6a048307adcace2d4d2dbc36753c19811f8d34a9b8c91d609fe0fd48c99ab6f419ce5f7da6a467b0e3ffd47d3a5c5fdf377b528c5f0ed54871f9628f24a25ff0fcd4f5402a30c4b8eb09d9131d04d4acd755d6af8c1ff83b5095cd09e114007e97ce6c30c7a3f262c922327e9d813d95a0d59e258e2761de9b88810fba74ca692375cf25f9c27a9c246ad6a84bf1858880712decd48695c5fe7ebf715e2771d3c0df8bedcd5704fcbe6129668d1a9e3de19777a14978d296a96f0cc958b89ccd5d02c42045c6f2c5795ac6acd7383e69dec95543d3167a0697ac6cff4ab5d1788eecaf39b633aec0795655d5621484ebabe08ef28ad34ab12186ba066b7655e14c8d5b30b52cd5c547bbb16a457ff3f3602b8ea1f3a0a2c7135e559e088f7fc3b9afd0cb60a14946f76206bb81d6ddd8f770bb8ebf40acb3e96b9a7f45e8fdce7c8b2b9df9809e47bb6e4dca51f1c3077a242d42069b396c58a8fb4729fe945cca3fb345ffaa928b7822f7e406dabef7d0c5697b9b8067ee9cdadd01cf5df35c30c4893a06c09262c97d8fb22c19de10962417216495090e0097e8388a2afbc56676a78a4b2bde2fb0e4bbaeef549626687abd7e2ff121c7b849097fd152ca2acadc8e2f21a8d8e9fcbe3000000000000000000000000000000000000000000040b1419212830366b746c735f62696e64696e67a26374627358f8a96375736563746c73656c6162656c78184d4143554c412d50512d42494e44494e472d544c532d5631676e6f64655f696458208820d319fe9a91017f55a37b38c62e0284cc95f47ddf8257ba7123ed0dc43ad2677369675f616c67694d4c2d4453412d383768686173685f616c67675348412d333834696e6f745f61667465721b000001a0acc226006a62696e64696e675f696450d7ff47e149a3f9a39a465eca1da79a196a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f686173685830ba5d003ee50124a47aa0d55c045bb81cc4d8f48f8f2af53deb5f9814f14bd78ea652cab197fc2348547d4cde771bf49b697369676e61747572655912138fb95de5c2d80cc16858659e90687134c7c832a60d1bc6d98a47c89f70bae04906d407cd391f6a20d8c012c1f02b4b2832795f43ac90a81005fa516ab0b5d668d89866164f1bf6f68d4fe0c5c9d23f8e1c4f96bc1f0dc3781ab7f70b91bd03faa4af1a46e0c8a4f0a5b3c67db1b7b7e494182af52708c8d29c2983ed97d77b4dcba81eba853f90351d6f0f366c199699a868d3fbcba0c7b6c4ec029af476daa498e4597afb85d4de823eb0faa7807ecfa97bd1e8951dbb75d04c62eedbfc1f01942c88e1409614c9587c21bcce487e51d99d863af58c2802073676ad4bb4f4ed66eeab2f7cdd0b00a9d0348affa7f835401f1594d9d510b3b0bebc72ce2788328fb6d411b67ee9ae500c2d10fe943bb59f558e9012fd5b8711bc5f318006413d632cc92882d729fd7d69f9bc3d77a08ce129b36cd5be28fc7be31644e5dcbd312610850b5913c213e5dac1a0e1d90f506da8754f9ee2fe7c14645273d1b21f750b68ecb7fbbf462d49afe0635c477b2685ed83881b5e667c9871389fd0bf4ec7e5a5218e75f9b0c2a38d9e16042bcc361faf6d80a58c8957b6c5c9606bcb2982ba77fa2c584a6c74179355999457d7516453ef417fd2edad4844c9969e5f00f4b30e2d59a5bc22aec2a95c731251c74f124bbfaea5d85d1220d0d71d2e17f048cd92b82c0ced3f6ad99a1143c886153454886189ef564f909c4b78dcfd54fe4967637f866acae29456e0b274ed542162d4871576616e657f1bb41f30d9ea4b751ab7e43df9d125c005f0daacace63d7d23666fb6972ff9958f11c7ae3c44ddc243a5381540799c1bda7bca77892d13b7ee7ac08c668e37b376f2ff59adab3a6a41ccca0845d27cc1b8fc64125c7144d4ab19a661b4d489ff77e76541d22e49bebdbd432683b194e338575038f4536b44daabda91fc52254f1d536d4e1ef28968fd71dd75a54b9c1b7bd8438500a679c3266e0893d45ad528795dc67e8c491ebe60a22972b917a114a9eb82fa69d319b3d2c54a0f74003adc805ba0216bb6d5e5b519e40af383cb2e1f018dea04e0068d6c3d5506ac602f22c76293464f6f8786a655be06f9841231d4902fedf148f211462b9cf78d3ac413bd207043daa020b68c0d9bcddb3bcaa1e7debc9a7ba562e72c3d5d1e1456f316d759dafcf595c3f597a6f9a83ef0849f5a83cf86057cce2d7fefccaab9925421cd32e66685cb7cac059b3287cdd4bbeac521056707abad020410cbc35e95005efe5d92f17292ff38aea015ac8a8be36ee7b34e1e4c473c82821de821def58a8c03d927054637591568d74246acdeeb9c03e593b60abd933530aa210b16434a4a99bc1d770718ec70cd0a195942b2e3532d7f19f4a7de64a38d2aa1e1dce9de9343d2380832f7e774f9670af94cea7e5c2951f338219c2b6ef2718192f16c0c87684382a553e25c6dbd8b4c751d73958616e5d1bff841f801086f6776f0bed077f06178747f275a1851a843944879e1439e0415ac0c40e176ed03933a049eb395f0b9d6682b1479bfdff6ab097058fd30bf863e0d35ec431f879b8e21763027a88e2f0950ddb7b68ebbf05543c5afd89793abf81c40598e0ff6947ea46253c6f2ca81877d618325353001afa49e7a9839d48cd2daec0271f29c74e14a70d6583e1c1b35ca6741a4db19cc35107921ec7226acd4ecb75c7218bbcf6ddbea1b2b2a0265bf75e97e87ff9fa71fe990e463792f913e897f5cee94b5daaf952a0cbb5987b8206d7c05290602469af3db0408b48bc4c54ce12eaaa5f317045e4b8948160895261ae63403510eaf280247ee610e3da7ea733c97218bdf68a151c9850db45b15fdaee4857d7abdf973e4b21b99dc308c255211a65903d4ad79332a280a6203bceaaf13323f0511da425ab13f1ccb638765dbc8dc940815d614423f3a84ed15d46346ecebaaea3472e5c5c7d3a1313fefcaa96f2eee8f663564aa8e38d00c121d45ab517bee0918ad2283d4e8d526319cba86b1d8641c045e87ab0dfa2ec3bb5c884e1fc721e2b3505012322c9a951d53aaca30f201d30c905b629032394b846e364c794b1e920d01f0999d6750b143dc2fa45cc4d149c1b79bd941e12b292bd9a8ff4bc782d1ef307f2967319a0bd9e44d5b7dd97679fa8f5afc1faabf6682e9e6a272b6571c14f75ec01f2d7772417b08977108100066c4b8b6af29d8da0e1a8f33ad56470e502cbebe1584d2edc6851cdd078d62300a6047dca236863f0f7388464b2f53f0928027d0a01ce24026752b5ede33b2c8c09eac9b69e0d84aaad7a3aa0c8e0cf55be7040397385955fab9fa48f431e53b60cdc90f7288c5d59263835cdd3574ee141cef23e4fef37a6a5591be0288d528aa13f2e0dccdb885337ad12a2de034f2ce854a037382a1478b587046022ca624f7fd8d39eb10380fb9fa2104734b69ddb1f037e1d40c0ef4b7ee8b4c0c3bb373f789e9acf737bf46f3ee383647e8e9e63f4d2cc13b5ca4ceb5cf7b1504241f73277116cec992ad6b97172ffefc4d671a0bae5f39c69daa33cfacb7a1b3e51fe38f2334f209945446f816f4c239a4678092ad31c549725f1829f52a177545935c5d93a0b20b3ad161deec40ca964aa2da5dacc6b3eb7f5020ee637c601fc2953fb4cb20dcfd32d8b1e5ea0c1e54a24492ca31a9a2c758ee74c05962855b15272fcfd3a885c0092d555880fc9ba4920bb96cde10d4399eccdd176ce718b856e3f4c1cf7172f6618598d3b22ba4db1a93f423b4658b8b689a6d27f11c4c1aa0fa988df5d2e69931bb3db0238ae0009bdb7817784bc855d1877bee548a7fec5d8e3bbd84c710c5ff4b348ed320012c5a7ba368d45902cfda7fab9e3b28094c8ea164ee41332af4ef042ead210fdb896f52c574cbaa80406f99046244f6dc188bbc9ea29d46e6fdabe006329fd8f5249ab397a278387ee4873013636de46f58b22b2bb72ff1818ae79e2cfc157c8bb78767afef7f5837e82ab75ce8cc12dc71c44496e487154be87cc45e051be38ec8f060a41481d3b17db2e2afcf7e0187197b4ab1a9871c0bfb380a9d3ffdcf678663bb961abf1488f103833644262ca1f177661dd4967b7125793a3971d26d82df9d3985747c11a65b07837013cbc8fb32cfba9c69bedfe246a319dae5397bc803881646faf459d5d47cea62f20332811f7f560a2852d9c666d321115f78223825c8b7e935b0ff8d63a621c4ad438e6b7c604133203d58ae242e2b991c0e475b27381589e810f243ad1afec4bcc570dc321033af2e561e6d16e597db734617ed10eecdc6035c9772c01f1c7efcb314c8ed11657e248db72e3fa65af47246ed4db718e1c38540060b877010c188a41490ac3fb8a00a23cce774b05d038eba7d1847a5c0fb59c72fbb3868fe743bb1ce2a3836bf7d79efb6e2a3335dd3628427d1bd8b9db9a5038d51c972232ad803f7e93556665a7e21fa265dde20fcbc1c31b807b0661ae6ff2e95fc86a5235e84217a7e53571a09e8fb75f021d718597624d1d68dd8f1e5b2e996a7539e14539431fb8928087a429a50ee5db14568a4822b3515a494fc2e666369a83ef2d51dc46d1819c1a8180c2074306ff13351f58f5e1f18f7858f031c983c33600981d1779592a18e2b46f9435d5741a083b2180515f6e42a8911f0d2754f8db9dcbc758ca28975c5361ab581732acaf9cfb179b524e2d7a1627b97d9613375601f7fc52674d0bc0ded5f5851ce194bd717ef027a3cb9e3c70c414acd6cdbbc4da7fc3cda39545d8352e3123bad17ab2f0b662dc4f8559f697f87bee781a7ed79d6c15978812d74533abec9309b8fe9e8e98817a3e0fbbc25caad5f1e13a4a8599965fa904a27c119002a815d8633479757f49e1d1a22d3ad3c574814ac578d2e5f400a8b754f53dc2fbf4502b25ad504dad60d5b192614168c67f22f1673db290320dfbc5ff14870127a9d1d0f80f21c7f2aab61ff4487adb65c36dc1e2e6f226f497d4e31a804ebf0699ea59689af0c8ee21e0527d7612188ebb14211327bf14b702de35c3ff237d16d97517df150534a34cdfc93f0de859e6a49bfb61d93276af1d0ef42370e9624c36c85ec45ba2fd718a817a429981e1b1731346cb2b7efde4137b6fe8abad5401e33b9c1457a386e9d2f1785accd5a06ce2a3cb64c06da2dcf60bf6c45e7729930851d340a1f377c670ff6460aac3505fe25bd9ef3631e9b3671ef4ac90ec149aaa9338b631f50f278ecb3e0b923108325f98666365143febbdea0741f5051acad70c2d57eb7234e28007de611644dec34eb38898c67cd25df9971bc114de7fbc70a00eab38a124899541365ec964f4d1bd044f9c6912733e703ef45a244ffcdcbcbf5c843de7c27f6b381dd86449f10f6c9907d3b48518f083e042033fc4f96a3a85b4fb23a6ee4737f5d85709acdca44108db722da0fbaf8499b551d67e555f08a8664f6979df687422758de46f14b92b2e81d58ec8f9026f4676f2ca17a059a70bdae6ed36b97fc3f4aa4a029ebdf796eed8ea44019fdf34b97e9895c18c03a00ae7afaa86c47aee5863667c526bc8b6e76460f84faf768a4b3dc939663327f13e5edd3ab0043ea50baf0de4b7629feae97255b1dce31f6f86dd4857ba7655907c6f1fbf489a525aca3124a28b2c395cd6b10ee75d70eb61b436e9bf11661773929559e85ac75e152cea625998fc48553e335bf676e780e9fdf266e925138888ab45202188d800aa2c05cfa81fdfc2e1a8572a70f6038e4507e6192a297cd2b28f11f1292ed32f2af003aa0e0b39d629d0ee732e208e0e5ba9f302717383a682cf1b7222d23035596310ce78d14f1cb4c7c4ab95fa4536988cb71d47a1eee4f4de47fefcf0565568941e3539aa0a796526de634d6bf65ba7fbf88c3696672f8d1ba938b76d6f0d7f0b3b9da41b0e3374ea78e843538971ac04d46e89df973e8ab28a61942a9e19205146019343194de6dd4811408138a9d47ccb6ba822958ec1c4787b7c40da93935dfe137859185900a7886a1240fbdc65b3151353d80770e1a9a45b4b27ac278d085d122dcea41b4e0810ea33331e103378a4f8f0f84886e2c8c746c24474f26e1528b4591cd2daf75a459c3a01315278235c7b279291879fe84127f276aec1faeac2814c6284725e172360dcbc7149093460d9434ed5290a40e3105402c08147205f79dd5e82264f16c8c97c9bbe9b7883723adf42e594bbf4a8ba7b11a34ee806f5abe5ab266f359c03adb645a8686613508a91bfd8ae187999c49e21230682f5d3fd555dd7f1db76a01e1b49ce49aacf0e58ae2bc47546d0fd19264e60c8cc24fbf09e3d53f3cf01947642ddd20c989ad51cc45e10f65c51973301474552968305f0c7882ddffe29c6058a97af316a82662862c31cc77812c494389c41ee31fb331a2ca4173feb071fe2591ddaf990bdafeef0c01d78220fb7039b56a989efa1256e8e51ab216499bf15de25958c7062d7766470403a68cf195fc22f2c6fb532ee7ce16740848488bd90f68d3c9648d57cb6151b220770640ad657e744e07a5aaab16e938e743abb4398f982c7c22b4926447de98922963f58c8b2da349258ca705aa94e70ab2ce36c81d11d05b8e6eeb50dc46b443d343152b415a4b76eb1bd82973aa73bcbded1f49c430803a92bb59f384ce248041f9d8244b5c49b734f51e02c12cc03bc0515726966dca9d771a03d88143148fd189336013b82fa2941d122f0692d62c2d853ccfd4d28f2e56a75b4b532886b9104615aafdf9df7c223f441c38e439a0dd547901074f22d1cc26725a20941e8b658440710e795ef73f091b11d734e648907654cde2e132369bc4d46e4f19650f41ef2524e8d32cb6f9e26ff066f0d918b6d0fbd8d85c1afa12d5bf7f6dac315cfb7c49b4bb13256b0773d35417b719ff6346515be6199d55dbfd68a9c914d99e08fe7fb7d59d6dc3ba0648d0a90957920727a20c287f549727246c1867b32c751b0c0e0745e3204322060796161ea15c384ae1b88ddf259dc8a53af8c9ce885b9c84a54751e5ebaa20f8d346947f66905ffa613ad6b0e008a4df83adf4365e3e2b9c6b2eb83478c06c5c07554138b9f0575bc082c8f789170a8462b411847f38ac43f58279f330a389d94a51099983b22e86c4791c06366059d9993eebc37e71e4c72b81778767db7422c82ca53ab417ef8006872e6f65549f5c1f81edee6902bb47d28380785e6aa2781490cf91c74ef6f2352a74e3bc259469829b5010d2b2f2f429827d4e139ce6bd5f7347628998b3ec5fd747565d41de3f8fde2caa84be14e59c24a4150c91922325cf9b759e48d10b4c9f234e6e86cb13866a33488a2096191796c43d0e65d35bf476abd3b6cc753a1bccb98a326935ced360e999648f8d497123d4416c40e85155710865786fd716808197a5a8a9f70e5c6d8f9ca0b6b7c4ff5f8cf813193ba2a3c2c708232d37acb9102847b1bad9f71bfe04071c334a4b71dbe8e9000000000000000000000000000000000000000000000812151c22292b356c6964656e746974795f6b6579590a202625a0a08208c97476370cda32602a44b57fc7b23111c99d0d71501215e83d75038288a90a7e7ecfaff7a53ae4663fb6bd0cd206b727938170733885949385fd4c3df249b3826c90cedf9509a707b161e2de124b9e3a1745c95aa65f05540a699c9ca747d7e1340845988a8eb154e100a0ecc9fb8be1f5cdb92962de4d6a0491da518db8e27bfaed2f707db10e81db23ead7da2e831c2ed69eaa24d8003e8f26ab492b75de1b4aa91b62e535b43ddf75829950b5eaff9840dabf352232944bf4cffb98e07aaa295a9f8d2500175c6d2e2a39d3d6b906ad2e8fe85b65f22b442d12b0759e3dd8b1b51ea3ea3ea8615340c0150d50337ca80dea9400a5bdd1e2e1e487ebfcd6e1c0b8d322c080f4c59267d108803a86afd07da4522fc740f66b6bce639d54abec4324783590640acc24d4ac022166fc7914f99f698339fc8f1acb12505e68b095f17399f7c655fccf3f64e3d966168656652f845b7f7dc60ce6e04c71ba865ede4dab6ffca101ba80cc108569c2e73bc01e9ac7a76ffa82a5857b63cd0f0f82dd9832723aab9b7a9e8aad8bc7d42faf922d9e433b2200f73ece4db0fd08c83721a766d5125e0057d3f46c184cc5429efdf60cd24934db02518929b13b2594e3f9a3fb01ff632288dcc664ff34640bede81864d96b6112ab73f66b0940fc3a39bc04420b3f4efeb267bec44be4ec2098be8680b2a996aeccaf40b2d3ef1e743cd960d3a05071845e308f549d11af599b943cbf0f303d0b85bc524f6e5048698db4146290e0f444cc9f682257c2251a2dedbfee4bd0854458221b30c5986cf59d73d77c6346f5a69d8a5bf43e863f1a93bb7df2fb0d3be31fd22fb53ecadcad661c60f61d3f42d407c9915a699fd6edc272b7df53fd0db3598bbde2c7c8c2b08db2d8e055e6ef5367d3a23078d5afff6211b0fade3cf46d7b7cc33aecbbd9e1bad36b2d042ed2b99f81dd6aeee3482e13ff8c42398d653e423befee93e57494ff57c6e5a1d7f3ed7cc6aab2f906853db25ac98176051dc40459deea3823381b520672e1bf6b423165f2f2c36ac0799d230a6735a9b9fb911828284e1f8c65c064be1b6deabbcf87d05bed5eefdeb868add89caed0a4c62371b1122bf89dd65e51707fbd32a5b94fa707a3e106a1e27b5168395b7e1fa2a6a546184747c6859750ad76d2090bf216abf27f74b8f0853d682a8bcc0de98c86afe5fc5627aaba17dbc041c75085de861a2ad4d7237de7c84bf45bedaa7c64cd061a04e265beeb57167ab814a7e69c343b05f0db0ddadcbd15b182565847afdf824a9fd1531c1a1c1e0702f15e77be455cc26f2a85743b71fcbc5c1b381876c22f4216b5d6ddcccc5881d0817301fd21aba9bbe44f73bd3abeccfe302b937100c48e9f626bf5a9403db4491ce134d08750c0ac02094d4c50322c4eafb6d8d91cd69979c6f1d25d7f586650df69854fad6e102acd70989788e5b4c863bdc4970d620b7542c7e6f8dd4b31a30247ff9cee05206d5816af70afda3fc0b2b53d94c4799c95fc674af909b7d21540befa481f57b6463dec417a2c94d8f01f1cbc9530d69edfd9f28e0a2a91449794731bcdb46a8fa0dae74fdbce03127595e908b7d90b5ec31e0ac180f3b4c770c1728df0437f00eb205d56c86f0cae0126b9f9f6cc7e107410a3b150eb005bc524bc1f28fd48ca44db7e0375bdb95fd805f8afa4da26c50f84b7b580a331dedace06d0721cb9c50e5091df89eb98681dd3d38e04a79c60eb624ae61847051a0c53d5a10537ff525f759504a50dca42b2ab3e6a661fc335e54db2cbdd4336227093fbc68388fb427b3345d57e4f82c583af83e05a4e8f33f504367668b27c83abf08b35c5ab680fe0f5ea442ff5d094c65da9bc02ce545f10c0efb64820c40965cc0fb00092ab73f5bf488ad99b7198e23762c200bec3a4d93d7de9b50da8158115697af6c4598f1b7d1af1a8f1758c9366b2521f60cfbd5e2700a839bbf4811e0ad5fc91d6935363ed0c24ead03c382d1d9316cde26c891712bdaee21cd1852da350a1ab63b2b103c0c346864abe3679f6d4082bbaf1c08d901ab709ab83ec6630fd82c00545a1f6d852fe0a1c3d41211044f1574dc8192d7c25263b1c24d32d28511d478f4927c4177b3cdd836496edfdc79c8729192dd5caec7589a4fbcbd87880061f665166c0316ffe0cf9a3e7bee20f1c380c5c4ce7cfb36ba580676e7d19afd03c5ee3e07b9d868fb1c178d3acd3f79b8374b243a662cc51b5c6bae5b61f477dceb104219ee0377b515fd710a4653c099a86d883872db9dbd485bc17b4390dce67c57a10eab10ce59f62cf89d7d21b8675399f7debc0d6f3f72c4e6fbf5b35c578b240cfe2477d7fc1f62e54848db1f1dd4ef8d1dd87853095e3a276d1a539311bbfef57c640be83cfa5b075e6fcafb88c0fd7fec8f62328ae2597ae81c14bd57435393f26973ffc0623c65b7c1d777060b1a13ddca64478fca28916cf4835af4c52522ea21d83996918ec1d6ebfcdf66893a15f4e269650b6b743262a3f1d77eda4c7d1ab229e0333b04c594ff1201d6e89d214e5c9367ff074453a36b538df095c73905961889971b33207a93ac88b8800228c84991b584f0cc6636890635faf38f8f627057c978f6464c52f40d782dbc4d40afc1e734aeaff6381445ea4988f6169d34130ae7e9430bd85acf6a88671b8d03dd0912c4eeeccd06746638106ca24728718f8ddab2cf3e51c6bc1a2d43f4a24931aeca7ccdbfcdfa35a0f5533f56e45a1c9f826eaeecb5a6d1a3477684458ae8c6d0b0837d258e561be33c6de21fd621cb831c25d1c8eac8230636bbccfb39b955c6c466ad6f352fd7777b168657799290b1340c11e6ccf0f3b0d5d717a565285f7bc6f7163201d538be3953c717991a6a591614ef4eebfeb2853d8be598f59848df974cfc6d4815141fcf3645bf3476292d836dfd7e2868a524ab63efba0efe990a3fd3f909b0a4cf85440d9300be2ab0eb0e64157ba727895d900488c31fe39c393bce02f3d5cf1582cfa165332c5fcba4d8e06477b8b86933103c3175fabccdd28d8c494fda2509e83455d2e97b12fd628f53b7b48cc9145d0079eeea8e4e4de4ba6f5cf118b20a422a6abef5dcd90e88c6396efe8cda7d9aa4ede81e20d0dbe65237a406e36563999d9a23a82e4f2ec0a458cbdb15605c1316c9b226b771a64f2961eb2b2b454692c796c92cfd199f47b736809130ddc4ca18b35254a504f710c41dbad6357ac939d453ca213d2759469505772922bbca0f2eed42f79a79b5ae95ed515319ed8b36461a56e72727cdb02c4b87a9e36bdeea8b20124935c6cfdcda2ad779d072fda36ec4eb2a7da95ac07c023eb5fd245f4fd69725e848e36fb67483ac2556c15d0aeacc808c6a702ee47020779409d7bb2a9df421c7b107c8406e57f63c9c3fa11caaaae36bfd2408941d6e7f208557c8feb14c217a98174c5e406105df4e09662449a3e1171f365a05e0b2fd7386205c5a7229b256bb1d13663b10454c6e4bb1cf07523787a45d5e25fdfed11549553de486924cfbf376e357b00498558ec9ded2a20720675d89adcedb93b8f47275372e3367bff880208222e6b75b4ab2a2013cb2689d22ac403749556a6279bd84219419a64c0133108a0d7", + "erlang_connect": "a96570726f6f6659121342c9ec52a6657d9c6e8dfe9efd12e69649a6fedd4ad44976c6751cf744ce7768badec1cb3589075489801a437140728029dbb98bcd027ebeeb0962acba7897509963a3ea28e2e5909591923f5bed6ff08252ee6917f9b7180a85d3bae78d826a116b2d7e5ea63a31fb1243479277cca4b2f98aaf35ed1279a7cca8a88e4db1ede923177b7561b5cc952f3b94997063ebf2ae49dae778817fa8214e3ca5ba24ab03f39013caf6ccd892225c4d58fef743d86ab7f9e24f742652a2d27cd582a31509df7e7a1a4b237a3e74a36e17a956f46c018ceba2fb3f0ec5aad16cc61606d87140519d5030d18b86a6de26fbb0f47725395560248a730e77c7d8fcc48e55e188e50f5ffbd06da87f8bffe62a120d1da9369881a60887f2385bd1fb822ef368735df97b1d19f6b04367e4c0464c477c0a3af3bb523b553cb54d2c2125322781adae1cb5c0127bc81eb4ffe6d297d13333ab9e9a679b44646967f30915c177baca256b341065e84af9e4aa901bfa8b32bdebedf6f58a2cdfe1049a8867dff3d76685ba6484ae743619e274229806eebcc82cdc62597f66249c91fd9a10c5bba6f419cee2e8e398843cd0f1edf358b80bc8c315893ceb2f2f41d8385718f164e1f28d48e69271091c02b0f155ded277e56d3e6e2b387eed9127fb8a0b550f2aa3430ed4901ee9eea94af3f2985f9a407112963a4659f2763caa3e9c30a46f494d52edd80701503b48d32eac8157eba1fd8ac6a96f0db9d6dc1893e6b396ebbe15be76024ca0edfdd0f1d5a21380a106fbd1b19ee4ae7031d1fc97c5f1852c6419687a70e06a263acf668c1c1cd13b2a3998a5f1a8d91db6d00f500b7d5c0a50c5d108170a727cf30af90bfc8a66825d448c21a99fa9c21124286d162b6d8c001c0c46a3e6b64132f996fb907b177398f09e9dab192a864459599bd880c7257f5784ef765ac8e9add86b831e8c2f22bcbd40ee15e190b043568a147ac4667502d5c502058c727e9ae11b16203f53674a3464aaf663d54b87cdb879d09bc7ff388ea94abc53d133519db554d6f6ed869eedeeb15195940fc17c59969863854ef519b5e2ee27b62fbd811bbaff517f4b5fd9865c6d6241b200696fb9cc9ff3856038504b94217d70ec312117b57e042b372f9c75b7cce9c5d8dfa496d2ffa0005c62cd24772836b9528b17bcf86edebb92ca7a5972697c38edbfbb813ed49a6be8b70430e468feec3d24f2bb71fb761c4b903108c9b00e1aa469ac1ced16fe02ff1c2d9d0fba0eb9071ac67c5d0dfc4768b249bb15ce0f91766e05335a6e3c997c001d2b88bb3fe6580f151fc4c59a0ec1e59bb6d61bf5679eaa21dd573d1d8c91832ef378a9bd6e225eb171a523cf4e1b69997ae8566d393a451a9190e769dae91e1c18806ec7f39b8f5f4f35f737866e8a3203bd0d5ee5ac0421a1c43a1a30f5da9acfbab254065bb50e9d4a8be4673ab411b1699ea20e8a974b9296fa421f362a106df8e399daa7b50b4f9b4343d1ac9703844176a7a622f7561292ba1360eafa18fd94a99e27d00d759db35c9615bb5e4c069eaf7d9b5c1f2ad7d97b18d8434cf8a2c96e98777aba63727f2847cdc199f0b28d0afdd4ffe5b41a440972b238f0ae06bf870a3a2348de8beca31ecff98ee7286f1f413210ac18dff616112de83cfb00b8c50aebbd2ad208e5a5949b35ee60b0b65b169e823b47ff19987426a517249b59ffd7c9ee7138e238bb3b989aeb83d1694dd78f312fb3354d8ea3afbec1cc0d2e0c83a12d5bacc6e6526135e6944bf1150cb0abfffbbdb439b948ddf60b6721242eed0e7f93972dcb120bb9a80ed7c2238ba6ed043f141b0b717de69c52e6b0007a7f6b3a5489db5ba2722a685e6ff80907e33579da29131eaa5f241ca322a6667513e99db9167f13f4137a684e4658bf6a02e8b27cb153fcb43df305e38ef6cfb8393502452368416fe5e5587bad8afa9d15796bbfd39263b72686c51bd8f20fcd177da45b3d8a429e254158ae4014a77eb6171dd6e52e08520b068c82d51d176ffdd42929f75ab2cb695e7bcff84738f38275978302d95a8a5539bde16f0e64296a617ddcc1e5a1958bcce777a065cb651dc6f65067c2ba5060e000b0f362c89d099f71e74922877d388730e4cf24942c8bdb5cadc3e71304daf879d4a5ad3638e572484645761c670904a5df674c03e15aeddf0c76ef48cba4f95ad2c16ef55cb6c6f09e9ab717d0e506e7f644080be02a02bff79f174ad49710de1450ede82beb9a9ba4630d9c101e6e5b4f6f68b8ae7a2072ba64861a0799ed2b4e0dd6c5829f59e829dc076716cd04997417bcecb49462b529397acad73ca056b1e94c8f45ca680669dff9273d2f1ef1278d08373e6898417fd443c4c8c597a3658a6e459baacf2aeda22624e92b2c4b24f6e8107ff8f1a91ac1f5c443938a4d64bb959de6132ca754b7f201a5e63f0468cae766c40b3f395438f55afebf93a9d99dfc91f98292827fa032d00b0ef97daa32b51f0ce44feb4c2d0f915bef98dfb15754ee8d3efd0c4e8769b07247839633dfdb3bad1257cfdb1c02a86fe7f323c74573eef2ce51c1fada847f078baadeb0bdba46c07cb9962ba0148a287b06fe4c75f53d4cb86e33203cf8b503f86250ee2d71471be050b0635f7ad9897c7acf638202bbff2c79bfea532d20613d913162da6ac7d294f874f27e47b15bc21fc08438dec4318fc9aab0f319b21575148159507072554713a4a37341003f4f4b20464ad4bc4ae461d66d0d8f613298e948d415a81677b50402949a61d4955b0ad71c2b4821d50345c89fc50422d42f83b63eda1dc7d6950653fa5eba039404bf4675969074cc3a6e42ad59caa31c9933b593a02179f475946b104f783d9bbd3433a34403dc56ed188458b8ead5ef45e6a76733ef6a40de743cf0e6d36198d4b5ff5727a1ba1a5b3c965acd46623b82a255ec5556041566f3ee711309fe34704bfbd90fd6eef283c8d3cf01cae31976ad08d5c3ef038c2493c9fca97a928b5dfc3b4f887655cf5bfd4e6fa5ab4ce0c713920832056fb1423b87008eec4dd6160806ab4828dabfa4dc9fcbd05f62b24b32681bb4aabfcfc053b26b877443b297d826cb9018964e92c7015c568bf64e9c07ab14527bc08316e001e7bebf1c8cd79bc392c08078e5b9f04079cc34a21fd04a846c118ae44d226d3adc72538eb25e211ddc22a2835e0fad763eb34772032a78e845a5e6cc93a773aa13385c608fc190a7b172e006d51c29aac95d853e0fc38518ec94589725e4d0913ab7e7c6e6c651e1264e5b3e4bfec39a17434c8891f2c1cd10bff765ae11fd202d325b81a0f3112290be4abcdfeb4b48c78398e7a38f4ae05fef0a1dffbd0dbf5d20416fcd238d86ed898b4508a643fd8c431bc3be1deed9455b280c6493fc69400525677486cd5c5d235127d27cbd65304b9a37cccf2c0024ee5e68b3a32122b095ee22c2afab64ad91024b8c0a37317c3bd2635b90964e1af274cfe28a48a925819dfb0e5e4ddc6ea00b59f36edaac7ca60fabe14a4d23af5840d15147ce1f8240586c3c921782310c1a64489fe6c7400c56e8beee161623275a07b7d935d252daa3f776a3e2f57689c490cc0f2caa17dd43f3c1c2fd8d85434e230e1b5eb15ff871df5054eaaff6fa77de884cb5ef9b65ccde29666bfa35615953081a7c14d1b0f24d086174cce0f6a9b16b157c2109752c623367f7caccaf8c331c260e17f7e2f344773a67d939e2689335041088c2837b4bf461963795fad9236803ef1921adeda8ef82474964430f3f4c5e010eb526de5988b8a69f2e0f9b8a7ca5a85248ad4d164257856139c8102b17f3b9cedede1ff9d3787168f6d4ac98cc4aa4f0981f4ebe135016de74a8f6a2c9192d046e7b250183187ef7e2075c8ecfa29e5166ab2132e5f71e0fcd2fc2655adb6b8cf805b76803eb16a5af40b78ecc70c3c0ef3617265e1b6f2c1c0c204341552b20a4b856a6650c5ee39a5b70129f64c9ed5d70164c90a7a2dd2830ec354ce4ebd5b5c6934b220652b883e2e5620341b5f0ab805904265058ecdd04933aa4241712c020096396331555b68c8d96478d5f4a7b6f26639471652824b96b3abbfd5a5b379e9ae94083b426e5c2070f8eb0afba2508eb65b5ec2453af272046e8a261c5cd19cdc513c97172689e57ed0d5c36e6115c7db49c91d955ed2c2438aff54d6c6fa1668d6fb77cd402ed02112d49b01713d116e6652295f045ce483d97483c55b9ef2c0db67db3ee59cd7cdca73c1d46bc23d109121d1b00bfc740493198818fac48e1df86a80e1d88785465a85a070313f8e78d83d04a38b4ca2e2c3906e95d3abb9b8ea87d9a6a3f05b463edce1166ce4cc84325ec026876739bf92157adefbdd8f385f6db0169de8cd7e0c58e64876d575de0129f8ec34caf223bf19313997ead86eb3361137d671b30d770c0521ba9812abb5d33df3fbb177163e5b3e876d5af2abbd098bcd06afc1912fa1c895b2e9a30119eef0ffc25de665cd8637012d5b04964911cc7adb67013058a17332565b36bf32e098ef0840939263268f2a3110d2cc4d689775154b30e31250a59fd9b41f47113452de274d3ad9425e8289ef711f38e29de8ea1fdbe0218b3c6ba60acb75f507f4efafbc6cd64606b1aa63b62572f5d3212ef4cb282161bf2159d2324a669b19f23abc8e80533c656a90158a0eb513f1e322554bc7b1f3758d29af5ce68c2b9f366e4197cd06cb6112c1800687c599018913fdca74cc89387f8c7f3ea1b702d6b9ea4a84b9a5e56a725b9d9a6c2d48274f4c77c0ea1976e360938046eb6fe6d872cb2dedeff2c82ee489421221ef109f8955e89e1b3a98986343ae3ab913e685411413b1415594d91324d0406e03c249a2861ca3ebe16ede8bad36fff1f5f7851973b5dae73dff98c587cc5543d885e13b9f65f65d1a64314e38d8214d01a94a8924c772dbbdb343557b1f2061103d6e1f505e7f5fcee849ef859ada7609398df0fdba6d5028a5cea2b8b93bdc05ae95beb04181216cdf40c814a5198f671f05b73967767e6a544c50b1c6c7528988f32a143c30487d1670ec97f5a2c18ed829624adead4cb5bae1cb23c3bb8451f2b9e29a8091539b0ffee3ebb31917c1f1198988093c2c15d5ecceab525740cb50810b43dee6af8a92a30b8d908a14911240cdc0914994ff83542a8e2abe0618aded2cf7b0424b3ae9d9d42c27e49d474f230a13a0785d1f75e455cdf4ae7b86c50c32c6589e569e644e5f7e0219746a8bfd85d660dfdcd23e8d5d1835fdb314ad33288ac89efe7c30b58dc47d81a6d41fac54c35db4aca35d8833775533baf99fb20ca345c32aaa31073905f036e9bcfc975ebb776bb7443f31677c19b27b5450e4d3cf423c319b68c99630b6aa74b65468bd0d814934451b5405178dc1879c1115dbf2fa7a2195d49b333e96bd9277b175c67dc246aa46c075eb5145425a91095c406107b9419c8fe8189393b331e53a2059515c0c5c29e701c41de6b6c2b0ebf1623e03233f75099aef23afa830f58a3cedfc4fdb184ced766eabbdc5384733b1e6916ea5658b6c00d51149eada8283933ef8927476bff7f2a59c72f7b3d9305ba314e742198331e552608b74f030fd07cb1d7f1882edf15493657ddc59b9c8aa9da6644fd372eaaaa2801e6f5694b08f372bb21f8eca2e41b8249dda17b5d2735995d6ef1d00570b5648b4140d43b58c706dfb652d431688ef3ae9e6a0f222e8aee03795de8ad09603a8f4b9c9170498387cf36f70b938363b96af3fa4d2fbb89b3182e3e537295bfa79733611500757a90557024c369e84d0240c95c9ca9605ad3f6db3485a0781e679e47161cf4e796adaaa56ce36d41737d93962af16c66723a1b1dbb0f3277d4fb1c22bece20648cac5e4f3300e5f8e1699d231a84f108da5de472097c8c64c0a0e3691ad93d313c61eb845448f469a7b7373e12dfa19026fe26ef5ec0101e4dd9283132ecda39c0b74aa2ee0ad4d80db966b053c4c0b9e8b8df16844936003eb85f97c8b7c7d203252fa43c1b154accc83e4c332a493364bed984ca0d85a05537fd7613596d4068ee54bed1e8ad57ce24a4316d607adbe8d9ae8b4c83e1e007dac8c41dc3df49be2bac58657bf5188976e8c4cff4e0b66aeff3172be97769163a7158e40f674a0035867d9e6a366affdde14194103d80ca9fdf88f5fc286a820d9f29e03bf32648295eb27c0ebe20f303b80115c302104e5c64b097bbd7f197861ffdda63b076fee274d094d47e5842cd48f034c689415cb47327aaf56883599eb5b5302565bba7d9e06cfe11eb8a54d3ad59df90d93eb000f01f0f0243e5bb4b8bb480150ae1d10f1a583aeec57f63ad6b61a4030503ca86d0e3e28f7019e73cd57454e573007d9dd1a4d20940436d717dd3dae6f4275c9dfb26898fe8f217204f56606b7c85c8017c8f9db90e437c9fb1e3fd13273153882c72c5d100000000000000000000000000000000000000000000000000000a0e131c21282d316776657273696f6e046a6672616d655f7479706567636f6e6e6563746b636f6e6e6563745f6b6579590a206dd4cb09742bd2737e0cb1bae5bd88ff8c2c2e58632e86d1366672b0030ee3a2dc629c8e0dac3c3b43dedf43078276d4243c88777a167029301d26c0cd5036ea6616849a2cdf653e30ac9dd70949d02d66e51d7e1f682e7962b9c7e97d92f8c4163f38e1d67721ca9b54a11d5bd9f8c7af20980bbca5089dc47cbcc60c6c08394a74a4b3d352432d3f25cdd8d27c4a48b45338c9c55a5463605a69137dfad93fb24f7c8fe6e333d05509f00993f88d209a67d5933ff220ab9fc2d63a95f4ffa41f27b10cd7dc7c817165e11d3995b5995198efda9c84922e78a6e0fd0ec8c55e623df6a9cbd2ecfa157adb9da02b8a888cf1b6521133efc19430698ca01d1126de76defc3429a19174128a6accf456fc88589fe8ecf460b8a77506a8a391b88196d60d8836e159eccafb505f0540c33c01eebbaca614a4d58a4e97de2534f18a2d7dc76e85b12c887722438806ed3784ac83e370fd8d13f93a98ecd590420aff968ae8691de631e8e5d2511048849fbd453acfd0dc87661d41f0e3477858468d335531e5e577fdb4b3436a95305d150668c0b93b7e424c65af22711cfea00eba9db32b744518a2d7e38b42c008655fb16b4ce713ad600be1fe5c187815571972e6f3fb49336d06bb7c092346a61d6353c8e64b4334d084055020815a7e728f6b040c322ea058cfabe4d44a758325c372e4579098924e2ee7f18056a79d8b3c24cf9f20e4f4e8c5e4a5f2b126db0ceb2435e636c7cf11a030da5e039a47dbf5a4e966d3120031e8566a10278d8d2a1125cacbaa1800bf95c508a9173ce4c998dec54d10e0a92d78b0ede720b2237e8ee6ec138224416ee9dcf2d0be0a69f474fe99ea1ada4c7fae4f0a3511367a6b939a64c59a52bd1bb2b107608bbabd263302ce87238aa3fe74a7dca7777e13de827d8ed92705a13c737a50371462f15c9c52d399e3861fa75050bc4bd44536942b32f308ce3f378a0fbe0eb03e54d1a005403af88274e5b6aa571c363b408a223515e8ac90f18460c3fa3fa41aacc09d758aef1fae2090b718392984a4781017b355a910b0dea2e4ff6a4fc50a1d9d27bd7e3a2e044a7c54f6b83fc2f8e8a361d7f03928b2229b4085f2d753e576d0186352e1d24610f6c7ea719e7aa8abe376349e975982d4b550b20d4c068b7cd5bf968326da901f8b47c1a966b4b124ee942d8c764f331aec726be1c643f229ed58dd2a5a7bae76e0872a07364cc9aa7c91c6d8e92934c9896bd56f824c5f72da8fdc3776126f973c1a1ce2c34c8f684f647e2879ab3e454282d5f118074c4711f73de4ccb1f9bb93aecffc36974e2ef367f515d2a697712ad0635878e5181438922aec8fad3b58264262200690f70dd9c502eb1fd7da4bf7ad7dadd475de1d474286f62153e7a5a6e41850cbdf6c9f7565fd990940df6849a84ff0aad638a1e2786f5be31fb03a209176263fbb95236f3afd715fdcb33b8a3995aa2253ca58da30b8117aeebfab88865b56a7f139eff9585f4f5a28eacb791347698fec6aaf7e9ff5834864d0369f31e8adbe597afee7f7100a13b17fb9c8b1da970a49299bb68b21d7d259b0106c32526bde8d4a4f971e591083608cb1dc45f3d862cce1b4fb3551646bd79eb1c37f73f0465a19dd04a1d95eab66831c49f7029a38b988d60aea4f5c1765378a4d709861b4719d404ff6f50896793b83d186b5593336b06193d321cec66934643d42e97e44c41f80671e5f75debf4025b43c3ce91496da3374a9cc58924684d0a1f64784e9be7f1048a1f21c8be7006f6be5e91f65ed55de400edadf813320a6ffa581ab3e1e3ea1445294b5e2dc8bcca987f01042d8475b9508fa966ff424dd89b36832d1d2135caf504142292820187d9748185fdb6d0cc56c75eb6fdd6d4ccc096cb1b2213f9a6fc1daf4257e99ae3762a8905878bfff3e5b539bb7139d6c9f4bf70447e79525a65210ef2a2100ba6b9642c457fa3bdda380c3e28afbe3e2ec2b56fead7d90b34a8e1982525ec32750343806201e41f7438681831d8842fd189cd9e899731ed6b88ee6950c69566c7d2dbc30811d3f46b5283598ce2223b87b401d82ec21d2fa1a145fbd9927bee8054943367d36808f13dbe2ba963c6e2a69b18ba81a5c71604d364bbb8fe07edc54d52e8c622b336eacc8cace22471e208072fbab7bd4c0fb72bad8fee40c99fcc58a9668255d8996d37574a3b9b6a7ee51ee560cec6f1a1a2f8f99b9e7e4ec396362ee35b4fdd3f0b0a8992338d0df83eb0503be004a101aae4720b4236a9ef1899371f9b62d98603e21b28026e4d7dfdc883ce5b099233482d57902d3414f6bba482b8b988a8c98905a342f7445f30026cbf4babd1816e0e927cfec6770cb02e6fbbc2528d24dc5181bfe0705848b92eb817b8b628a97b680480175d3990edadd92d913020c977116b126156058acf514ac2aadac39e9e173d2607081043fe1ebb8be2bf085c6341b6dbc22a0bc32077eea12cf51c857c81d880ce83d48403310d5e3b18c2af1990c4c8455fd7b1171ab9238c698a8d4ead52428f629d52aed66aee0cb8e65ad8df4d0c920867716ae569cbda73ebaff3b1863f9770575097a8b05611e3dfa940d831c6c318a183f3e54fd2cd14f900400519a9fd9c256cc0d7f296dd09d741dcfb754d0ab4eb24f33fda4427e4ca1183079d685d93031ac9f869f77e203bc5f6252d342f79973e682fc5c6352d923dd1dfd98ba2881d2732915bdf3bcf422fd5ecaad8fb36cca6410ea19ba00c3d8fedaf51b58856c403c8430f515c8f2e39cc50deeff84328d2f66a1a3bcf9da0c579cd8d829999cef041f060b3e8b3cdf65d48d0bf36a7c96d3e1a2ff5668f9d1afc7b6489954f371cffc86a4f186f32be662af88121f8b4a0c0ff83c2e0dc511e896149cb1a230f29c19d89a25fae1608d56114a9396c7fac8e06ec1ce399abeae93ef2aaeb5df14e50b49c6146667ee58c16e47ab688097f8cec5ee72bff1886de1ca68a853044f24dc57fc36050357d1bab39a892d21d121bacc998f992f41a9308c2c0213b86cde4da48f9d14fb3b82c7235bbb803d2aeb3119db73efc8541a0a1e54b5ae28625a23e906f852779b977ec60750da93b5e791f2b5d291e7f969fa183ae0de79d32ae0038edd256f276f1953fa2e65b9c1e525487235a1c61a72c77947476e33c45ae323b5154cf903121a30744a78a9beea2e5edf081ff0686350b87ceba218a4cfac3141e9f94e820f22aaa230b9c382dd60edd0b52d434fb8c6e5aeb2fe76c0c8178a0f8e2f0d92f0697c6e7ecba78e091c9f6d059363b37b35608aabb8a2cc0fe368d1489a05bfd6e4ed0ac7965d6de26152df113d532ba2187159b533e8fdc9281c2995f9433ee0c888af29d37a5bd2f53a7910851ec37dc0a4403599a07103ddae4cd84edc10a5f19dc8c61d94eff7a70638a8a21a79e0a778798a1ac69b669178826c4cd0db1a72fdeb5f66e7a05a1dd4b8488731ac7a005f9acb68d06126f98cfa617eb2c3ee6a77063f41603908fa11cedf6999ca0a5922a3bf8824bf404e050a9a3a98eb9bf257a2ee067d180c53c6b911b66e5c566fe3b7d5eb89e7278dd69b6b21bfb9cc2d013212fae94b18806365aedd54ae08deedb5e894749c21c316d2c1e22dec6aef34437d586c6361706162696c6974696573056c6964656e746974795f6b6579590a200510bbe11ab145b9b893ab1f75686d7c24878a6b54a8f8beb1bf89e28ea8fd47d82ecc4e246bb6da1764fca648c6ad1783f07d9db603c67861cc0bfa5bb21356370ebd07fa5779c8ecda387bdf260e1609d4da2eb0c627daf095b79d0ba4b848d8195057b0e97b7b6567747611a42974789b9fe8a0711bebfb0c7b006c07f1352a6583f10dd5330470e534ab04789da484b049a7bbe11d2da8767c19d4dc9150c10f0cf32c30eef462fd6cf1e5b22f1e74b71b56b30971ed5b0bd6893f627a444f1c81b703ce3412c8fee0b442fad70246736887728e6718b44ca12db9c31c5a497491b82ab91e52c96ea56ec0c9b4de5740c154563a7486400796344578de7307a02cdbf36708e46ff8919d131873dcfd8a041824ab118ebbbba5e5fd7594d0d9eb955425184293660d1a1177365623e4b45ae34611604cd253c4cd96362f5dc56954856e6b54ad635b42aee128c29960b42f7768713e8a9104e494bf3ad39d37bc599a9b49e7314eae46a8857128de11c2864f83c086fe6884dc4029aab43823fe2aeee8b7c4f84a80188822defa1618e841b36dfc2b4dccfb38faa44939de9f774f61552f3b55fdc5b8fb631881bc5fc137f46664c7fcbee14e9db8c0301c03d492f4e512a9bd61c33b405ee84b50a57e3f2c04abdc84e44d67df6039ace6dfe98c6978e52d9ebc58f0cea494e95d695fefb24e82e6c6fac8c21799a159865ceb1ae305aa9d493c58c5ab06aa16af7e11bcfdc4a7439711012535c4b42cba1a2025eccea6c478f13f371bf8b5694d1a4965b79ec4747a1dd3c7f15eb6a5eceb3258f40e0f4a7f3e0898a00860174f08116ee04d1fa1a43463623a095ead9684d60c58eb5a60e250226145dbb87c89bf234fbd80d7d9702215385785af3582701708b89fda902cc5f0523e39b03da0f4e3c46e59d640f6cf251653f58364fc03b097bbc59f129419cca5a07f86532438e1633b625ed07eed16aa6918a976452dfb1e3664059c6a31aceaab28cf7a12bd712491759b06d3058309f4ecaf3cae5ba7e5cfc584bbc4f83cb72ec599bda4fa2b6b22122f65701ab1ce18873442fc9da5df956edd44c947d5852db91f776f9c29d250d4a9a07ad411627b4e91c232e3c9bb724005f68d9e8ef2ed70508d8db1745077427e47fba031c3e05e89d9bb05fe749b679b5658d3c03e1a0f2ba3e1a0fe816cd28195bf11a366b80d68f06c42ff487d60cdd658b99ff5bf103d66d5e7fda621aa05beced6c5ef6524246763d30680cbc2471714bb7ac7e506275923cde2326d60707dcfc98f489625123a5a5515fc9779f4488123f39080bb694661f3b8460bd1704ba689877efa5ed48a75d3e590e14cfbc083669f5c05e92e3bd85adbaeaba6e68445e5e6f6092f48f8a856185492017f682dfe3a55e99bc92074cb1996f7a1750351aa7f0ffc5f72e87bf5a458f74ae61dc3f6980fe62df21966d1df467a18ce9912ef42988e881026ad3fc1fe815021aa5b833f13d9e68c3d05c049a85b40abca95de6eb1857d876f903b86523d4e0e189ee15a9989eda3fb3aa4c42702622b02b2fb1c21e96bb2e4e94e7d173f3330854cf0d0ca7039923e6dd4434ba8dc355d98bbc1fc9fccc6583e06b2d63162a3cefaac37ea56ff593edee703480f2037997a7efff187234ff5f6b23e2a95416d9cf26d4c0a2cffe0ec5a8917977f671a8ad7f7cdd5d9e2bc998066b6db14dd8d0f38ec1ed223b372ee441c3de768883b67f6683dafd3d25fe21d80634f54cd6ed8440eebe308a8acfa602c4095d76c798264631f142b08c4943114395055a2dec748385e231136a1dc1d13aca9295db9d88719bede40d5e3ec3ffa34b0772303c4e2fa8472eec942913c1c1c670b8ae78f046dd8aa8d0f22170254a8207b7e6fd17bd560b2c9bf4515e3a89245a75db2a1d1bae4e001a893f968792929000e13d698189a599e6197df12f683ead5491d6b2a14779acf3d373bdfee6f60cfceb4301b3d7a222878b52c9921a9f80f4edcf52d26922812fd939a76b736d7e13b30a323320dfc44c0aef3027c4973ade622472c732fe64628ac9c1052f6e25fc28ab9fa48ddb42fad900c6fd151f83a3c07a00d6895e4fe86bbad8d20657e6b3a8f53a7a122aca1f2460776e4bd062b89139352a33a05c3cf780a9c4b1afac6c88cc6d2441c779bf292e3cc25ab9b33a75bf4473e8bbf758834e32ac6a2a34b34ceba647fc94312b7af7fb8cb23365e99bcb7b23123acec0f0054303572c64c8f75141e2c2026d69972512a78aa305ea9d73dad0c944c79159f00e97d1f534c961dca70d45c58270775b10610d8f4046c22b0b111c0721e2edec6f147fe912354bb96dff6e89d65aa9a734e4d908165f6122dfb260d747b1ad3c1284c16a8e7564a42dbf2937457914c68f12ca12f7905471ca6509733dccbf3aea5a9c0b920efa2d25b4f4f3dd4d10c64466d4ed7be9b6bf907de4d148a2d5e49b2a9b7c263ea86c1c65ee0cb9f8a7ef69a8ef1b3e62006cd93e7f71fc9b01f1768514d85ef5bf22b5a513081d2e1769bce8cc699414139d767edc2d2c0f880986d33914d023ec7c4bb119f9017f2a047d16d65041916322c7e56dd9815be5132473f079546b542861529660ee6f97c9ca6eb54f9549fadf55fedb65987ccabb596b7a45bdc41d824069c5b6205699b55f844183c6df5b800beedb8f48c3d8c77643f90fdea8e903c6a47fb659034850452f18c3ad82d497a5fd0abb6f9bf613fa05b94452b32ad6b5d36d1afe67e2ede003e6b223f6424ff55e0a91b6daca61ec96af7b43d16cfae87d48f2a19f08d01491df2a7deb160c158c1f708abfe4a720f61c123615beb28e297641cf82bc1725cbc7a23f2bd6d1f9439818fc112d91a2f6757932fb72c01517ac6d233d70b35f2925bac291ec16902c4f97cae5a8a932ad84e973918db7bbc111c7bcb87c055102ca7f262326de3ebf509d829b6513928d9416e32bf9553abda3a9dbbfa46a1c5fd8183cfc9f5cad3de25feec794b93bdd51a0ca6d7d55b9c056140a78555ca172af42425f9538f5a652d285ece2af88a756d00c4e183679ec4d14a2b58aa77b1651aebcc5b366da79a1b7b3505e66d3bbb2231738cdbf3506a7bb0b40ab14f23d3ddc7cebe442b50cf9e02a6c00aef200170042928186b2f92e7372eb9de3c0af90c3aec912321a2a9c4c3f593f43b3c93266964409837c54a41a2fb03963ba3950e64806a1eb91e8fd75925bfa52ed1448799f07b3c8f04c30f5a2ab42860df7f728d9666e7e5d7ee96da5106eef362821579a2511c5bf0dcd70cee2ed69cdfb530a928867b792f9d716938943202c92adeedf9c859d3c2c04a9b2261b65f9ef0941e26c6c8fae246940ce1d109115c0945c14c7f5aba76cfe1d2227c853d05a322c1f6be8ffc49280b9ee8d4e067ecafc416c392e16348823ca620bbc575ec0d4b6214365cef55ec6a8b4f2f795d9ee595e9dae16da7132513ea330da950e915c429e64a51db8a18977f291328f48d2e4ecbafb189bac402d676de3c7299580ba1a5c53622e26d77c38cd4f89f8299753647c26ce42d9353545c90992c8bb65baa34b7cce9ba2d231577df032970a83a8fda0e4630951e4532e1243cdf1f838276a661a73c26fdc622259d1191d3ba6e636f6e6e6563745f737461747573a26374627358bda6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f69645820d9f78f5c69575454136fa72a33ba27f9abf42d1aad7eb07ad18601f12bb42a50677369675f616c67694d4c2d4453412d3837696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f686173685830674bdc49dfb27807edf3f5af2e4f13638b13c45e7037a09b5bcdb51ea5dec0902e472754ef698e78365d8ff4b5fb7db9697369676e617475726559121337f45f0ca5bad4568bd98265e58502169914e04f287880d8c794188ac94dee2ea4a03d420e1570328375ef9cb887118a594427649746df3058a6d1f55974284706143f003063201e8e9c10673f4f96eaad8473106846ec605421511b83865688993cd18dea5d6b5125a425d2c6dde689545e8896c91ae7cf599d1fd3a9f4393bca2f2692de082790676f7158fade9841246ed71aa257b3750df85fb2afa3ef5085b4f3efb6c3c441c51b62552dc6269ccfce8c0adbe5a30bcf51aa5b1dd90dd7b477e742ffe5280e808a2769615564a8a62f8eae28a0e1d22ff9b2b73644fcac3efff5fada05c603962a5b38a5764c033d44513c252c45de84f17ca515b09a1814d27b21d74baaecf512c8ace7194332457c0d51f83bc404ab68a475dd514f9262f840f36e6737d979d10383fa8709129c110d6c26fb3a2cc0ddf63a24ba79d87b442857b7d716162c1a467a770bbf708b3a35f28e3bd93a9b262b635010a4aa9444bbd3a13089f8a756a25beff7b56d3f5d2e4d059c9c9fedd73378bea970fc3cec3266bcf531108bec2f8f07ca5fda19b6cb8cd42d7ff079e187e8adbd3b24414a47b6e3d7c065d65d1666cc01cb3da317be4ebde013ea6191093d54a777d9b307fffe49bb63a60fed11cb5d65df12a8c987cad4e841610db27735270a6f4103195660f4ae340a7228093345ebc0eefba14f191d48810a1c96f977a04f44aa1b78e77ce0fb309133c78de78b0e0c0e495c3b8a9d5f8b71c74bdc5d4e834c6357a18ff79545d6fbf78afea317d897bfeec5638484724592282d81164c228a4622407fd6551e6054cb06349c2c4ba84a21785ae8b20f3eb714b0d43a95f52b4dd50c9f37ccab0f246b6e15f1b4a7fd832bf38b8bcb5bd28f2a970000c8ba6071e58b266d27379edf60d48520a6cfb6250c3a06b0ec2252fedb89725ca5072aa99356e33deb6702256149973b453b5e1aaa52dc47d156fe95ac5b5bde4a5dd5bb3dcef7222946225d653ada8a9b47257cbbdb29b15ec9f5fab71e8d24cc7517114f51ee2543ae1115e4f3aa6f7338467711c9334807f3834ca0df67f0b6c2860606c072d47b5780471ad497931c7922a267c4cc13e7e7d4547934a1b1125b3248a2d6d6e0e146e52d63976298adaf800038d59bacfafbff6db69150477f5af43662b4024177a51a18b0ef0ee03f52a90925368d3839fd10bc859074dd8fc8ba5cf7169b09c696ddcc03cf67e5a79640b331b5ffda358d7852c777ec0b958b8a50c08577138cbd2a2de9b2e38c5e50ad4ee40dab6ec1aa93aa02d563d63d8285f5a17ecf93e5656659a8b186f3e603ae48a8e36b72245770ff9929bd6060d0cbc36999d55abf73f414f64a788740d8097a73ff74169f5d53a9aafbd3482f441ae1edbb4c8b8bad11feb8b275054003d5a7808865f5a5d3d8506663ad45c8ae5f016f3d11071558c70d0284ce20e20278f2ef86e1ad1ac43c2a2088d65c09580fc47e0c5c5e621bfb100bb8ff29f48abb8c5ca405e28d386ebb28a81a88690e103c1fc34c156dc5c750951febed28fdef519c7fee8c5fd4333e1a46109c603c0b3eaad25ab36425e241b308ba9394800e4e4e5d8f007d7541ee6697da8da7cc5ab447d56bb985182347f04cb9034e195e555dbda0316b875bce7a30732ccfa19db464b82dd579d6e9f1d6ee554b4d40dcf27292352e70b0e2e78558c89e226944272b90ec8960b5364077a4e66394f71fc4c33d3ec4a7967308a44e7cf7b55df0ba2b8f70d4d084638e83b97b5048db709aa753ee7a7a646b3daff64ed4e017c7fbf12296ea803721248b4f4fd23a07be871c2dfaaa657f57d5e843a0498edd77f56f06a4de111d9d2066c1d18509618fd9af533d1da73326bb6db086196679071a514f28b2c57848b41cc3b12f1e6c3200d813e0c7a598a3a6113eb8b78e5b8b233b7692c7359d992f14c1f9b09200b5b0a728c2807215aa3fd4ced0f67c287dedf08891a7420a6be26bb4df3e501a896d52e20b17f444839022d45ed2deba5d08373d7f6e43b12ff71b618e6e0bb1dd8ec1935ac4240d3970326fbad14f52bf38ec1b88d7806629e0cc50ba8eb332aca4b84d6a13a79f60ea4bc8d28342d28ecd851ac98947472678516d9194ac764b6f1d3bef3b589c58606142f650fddb1a05fae06142dfabf0d41c5c62fa02fbbc200f5ee4e897c5f3ce988a692001662b1ac89cb17dc66935aadd4cafab62ca3941e3ef0da10ce7aec0dc9ea59661202b86c3db3b2555be92a950063c7bde6404002f403edd504555d4dc38cfdcdea2bc958f34ea0b06553a3b757a42cf5b5322ccc5a9aefb4482f0530423a61002946b250fe5430b99e7170d3e0aa12354ccf199b1d56c5480ea71a73a91cc58b326296a4495e408fd74e8b60c2b3008acd1aa961b62b3d465815f22dfa97b843992dd4f75723fa11fb9b9444cdb38dc04c0ef49493de4c829ea0a25389b922539561e09fb252c27d47d5cc37f441dd04f70ab8cd7cfaa2fb8899615925181bb3607457df4e95d51835ea7abe4e7191c288ae28c052532875f42870a372d291d8cd1cc68fd7379528ed53d9af881b60cd191a29809edaddfa02388a02813981ea3bed33c0b8bdb2d63c86c72c3f103cdfb4f702ed982ed5debb1ccc13fbe6bf7f33dd3dad681c84901aeec9f63cfa1bc767f5fd2778d2f53fe98f37acf24fb565c4f435363f052126ce83561c3e43223d7afb7f4dfa655724082560e547077da1cf74f13f0aeb99189a6a50b2b0a3e370a3efd75a789590a4fab7d26d235a698fb7e5c27a4036fd344586e021a3ac8171e98827c94901cae6194607b92ef6f3b52001040bd944d3ba66909e6651522a9ce688340b00db8f1ff15ca270de6bfedc5a94e62576812f9c2b57d5cbb8a3f15ead18b1491a4dfa75097d81be55f2cc639775110fe74310d2dd17507b453decacefd5ce1bdadbf3837419fa494e0839a943af7b657146a90a084b5f2a57edc5f3c84b1bfe80d1ffd94ab5fd54575e1a7bf93439166715088ccfe4f6d1bce916132eb673ba984a9c81595bbe2f2399a9eab7e449069a37b61f9c816d34a3f60ee67d0c8af35fa88cb0e47dbc287bfafd40a0c9df762465d8d444f9f168865932fb9aa08d4e3d7b81db84b8111e0529c340f61665f3b135a2eade20b5119b31bb923f78f686830b2ec7d4bff7147e60511b3fe79b2e270130cf413d3719362911d27c5e2c472bccbc7e7021714954125f5593f6703341c0be0ad41cf6ef900c52f1c33b9d1c4ef6b8decd05eec8c067182755d29658cd70aff7ea2d5e5a4d85ac6fe0fc265ebb7c0788e636575124f461a16b49e54daa3bbbf0b151020414ab7efdbebb472aaa1064c1d3290d26d9a55113caf0de9982c3b2344d868e7abcbf4bd9cfb328c9ada71ed725e01556fe7c6e11fa46d34544789de477897e19040b3fa53b079ade1bcd133ff47e244abc4e339631d981d3335b8409bd1e4fb010694691ddf8a35918a2225211e387943f6db3aa9de2dd9c5c57a43bb18a40ae5af5f7f7cced4d42ccacfbcde074b54f2bdc132b3c2fa952e088ab1fc63d5ce10c019f87574cb3b11a552bdc3417005add04cb2449e98fbb96f1d677c27713b3eda15938889ad7ca0ad81507844ba6d3d6a23c45d92c7956c343daacd8f0e880a0beb03ad91197813b8024ce600e8225250e294652189d7ccacf25029bcc484b705a19f2c56790f2df0796d5e313db9795e4cd455df5b42ca49715b890e269df9c6531e3ffcfc3fc04e4f02569fdb06d574242d1a77d51189a3757c3b690f4cbeb319c87b22c0b99e291debb0a1b4ba6065f96c672ae6bf7db9b1be8e450023a6a0c62d75ca6e4712c0be96382536bd87a0b23ae9936b1c5f3e70ba04ee4c4b379f0d9db3809a1c6bcd119b4cd020fb042220c171fb33af2d1fde960eeef9b66a6f2f2329d6713901a8a002ba4017190e142f57a52cadc7ee8dc9ff9577f39f34e87737598a00afc9b98611d7dc6fb5041c42d74d04557e154c21376b1695da9795b77c5bbf0fb6f73aa37ce37cc418d3b448682762bdc1ec4153cf72f9b48f880472371855cd6debc58e24584c0feea9387ec9b6d507867f10ef5b04e75dec46d803ac064f1c62c585ccfd613ec5a07efcfbf22a732d55bc13ab49ee466406bce7be24f82849544c9b1def1593b999a93b7e17bb59022d82cec71a040756d1ae1cd4caafe18e52366002943eacd4d8ca364a68e78906138947733a942c8744311639ad1c60c18498ea9a59800c8c0c97e0a59a1c0c81495c14a009546fc2d71adc9e6b0678b50ac58b82b7630c21193cbb5b4aa79e688ac516cbdf417247da006ad74bd57bc50761619fb72117e9824b617049716127f7a426090887cf53024bd9851dbb650195ec5810db7676cca0c1028ee784b3fcd3bd19bbdedbe26c0eccf6790367f53a655dfb15caa5c5afe1868ccca589a6921ef3fd3d6ae95bf5300a7b5774167d17d8339e76432bda7487ada03e37b299efdde2f085c40465fc174aad36a2f1d4346a969c3e7519d6002fd8868c9ce998044ca01fe27a8e41efc3aa9df3e62194768bb553f92e8c56d783bd715de90c61e6beb859e83cbe58c290dbef0b813ff963e2d2dbe58b67f351927f36fc1bc326225d2068d4ac0e9d3b58c030a548fbc2e9616ee473e29dab5a56d014f1cc54431b99f382506cd16eb46ed43ad07331a415aa35959a47328fb6af21d158fdeb4e78aef28809884d6f5147fe3401ccb8459e7bc1a1bec1052bf2cb85008164fb8e3c441da84abc3282f46cecf03976b06ee0ca0e6ab2a074c3225a9e3cf1321a169fb0f2a765fd7cceca16a2199e82a3b371d65ede3cd7d9277188c8667edcfc8a37b1e08ab32600546c5834f4ef31800f1c1e3fa9f68e151fd4bfba9a7849433ba5b766596f443622014d010a978892ec8ec63e9aca1ea875c5b2ee944023b9b30ff476b6215be88660eaf63f07ce31b5b0d720adcf27dfeecb24829c4fa884b9cd750f0db6bf8c9fba7989a0d225e3fd95292c4a5f8a45651cf8a994f42983b0f3ec377ffd551eb7d8cc93335693d689d9ff9585ed41e8de34049f2601095e419d66568dca5293197ceb49ab11bde414cf7e6ece26aa63c3c853b54cec5c6e0c6745652aef37ba28a8a14dd78d58db32b156c16c50ebf59dcef38b03d3a6d22e4a423b864d2e3eea4cb53e74fe640e63a3a9ca9e426d32d5cced2b838094d54d5e78e06dab813b22c0dfadbb7b29776bd977ce15955e73806280be46dbc0740eb27a36f42ca93e2ed795ed9e39b10c83566cb6dc3944a4fd166409a35dd5286c2bfa86410bb846b78342cfa0cb06244638f751a3299f574f81fffdba37c350796acdeff91b99fe46b3c43b9732ac63252ffea086848e1888c4d35f426921f75f3edffdb82b4d3e3bbdb343bc0eef38e6d3635072991c46cfbb852eadb86f5ba1c42a909ab87f1a53804422f824afa95942abe70c87117d7d942e3d667eec97182fc0df16af4cef3c22f705f2e9a002809c23f83f46facd916eba28ae6ce494a45728f7216fb7ad2ecb53af2d8255220066957ae902f3edd85b922e29ada44aeb7a455b381bf16f1375b57378657074c3b831579bfc7545db07ddce9e5d943913a7e124a9d63141c100cef4e7cc03b73a68b430f309b60e94e5c602827dabf49ddbe409ec03c1d4fe153f04fcf195680a8053f239af6dd298bc330732e337f399371506edd818be82962763688bc0b736cf3881379be12fc2586c73419fd80f76e29f5d41e79cbd25873d5e0b3e8fa1f7affc25e7a1c6162eedc22185b45aabc9da6416683f96d37a016dacb0f2916e87d9f357f5c8067d6f5f93df625817b2689361e63c4220d6daac4a89c46aa82d273d62de5c225a6f88e88f21d98c729fed9bb2e3f652ce1c9cc409e19982f81d0c44642334958eb56a2c2458fb22f895518477e09d399da877caea9d4e90b54e1335ce877faf9f460f992a52f53157c191f088dfab0c85fc86dbc8bd8583ecee3085cdf65c2682b7f67bb2cdf47464de1ff0f51299ebe13553c5ffe8a8e589bfff000926aaded4a448ba5f933476b8a821c539e5023d006c7b79a31844e2f73919c46796fee6c37ea3d4554466d8a16a59174fabf8c03c5138bf6773934fd482f1356ddc5040d5b890e54eada5992a25ce2c47ad9e1f8ed1026cb8646a4bae15bf3d4afbd6901d4292094602fb63dd5424db3d2243f894403adeaa000886d5d1e903cf1696aa17ee6498c6a7e7847e96573a433055029292cde425056e13c4465d42fa25993c16ec758ac3c6d9433f2d6c00b497cba8da94bd18b222458aec56459c4c5ffe61343bb53f20869e4d0af5333175d567a4eb0b352e991f3d141d7b14b42117a3f982a0488a402b38738462d1ce75134a6f919294ed3134506e72030f24354053557b8a97d9fb062024383c4e78939babbdccf6090c5a6099ca1b3d41439ba7aaacdeeaeb3f40989cb3cbce000000000000000000000000000002070c18252b363d6f636f6e6e6563745f62696e64696e67a263746273590100a96375736567636f6e6e656374656c6162656c781c4d4143554c412d50512d42494e44494e472d434f4e4e4543542d5631676e6f64655f69645820d9f78f5c69575454136fa72a33ba27f9abf42d1aad7eb07ad18601f12bb42a50677369675f616c67694d4c2d4453412d383768686173685f616c67675348412d333834696e6f745f61667465721b000001a08ddbfe006a62696e64696e675f6964504eb6492bc89f612646ea8f0cbc27a27c6a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f6861736858301b0c3989df81c3f65380425584b859bafbc09a756877f743fefa4c8e2f5bbf322b90f130cac061565fc6e88202e08b77697369676e617475726559121320cca1da6927b4fa9dc324e55928dd72bd936b8c6dc7f39af19b86e4436b34a2884d8569847205ea61cfb4996f5c418175994caf0cc5b70abe85a00c1e315d099fed0a1e9f90d986f3a6a008dfc71cc7ad4ccd11f1366d51f2e1ef6d929735ed48c39cc1149f439fcfd7969dfed7624a1b22f216e7acd67cf9b1223c90f71af401aa1cd91c3c9eeb3f496b4bead7a898897d18f8f3b264d6ad93878514a8152581af495881d2ab558e578a74aab3eb7424d553f058fba249645127f11112055ceae4e4cb53208959fea68b3d042fcfcf699859985cc0d0fb73075cc367fe5e3474139728448cfca89ade40e904e7f657365cfaddf49a0ee4ed1c5466ea5187f8d71fa24f7df51b77406a1933f2ecc3196720c7ee3e4ad1a39ec9f5436b2c20d00334f56fde2d801fde053fc5b0aa30cb60da60ca7f288980cd0e95aad7a3e69941c4bfd31cfb2cedfd1dbb517190f124c94568c2e95f73094e7f971eb56a6aac07231d246a1c7a89a903fd9df5a17bfcd88698fe29e311cbc065a7b8217649b3e5b478246132e8abb4d785ae37538b3d4fad87a3ca75b271ec6bddd6e061f79bf71097057edb6c623c5ce962a3b137b14651ae0be04b8a645b21a1f08e804e508c2fc38e1c22e93872fdc3228f08a6ef10da2688079a6c7135f06288881f973a0a1ea3064fc736c2a25f082618089f95861d6c5117749adabb2445742aef772b7636c1cd21c83a865f4d9d262ffdc700ae55deee5cbe599f6d855d335dbb63f623e49778549b9d9ee8a28005b5c0dd701dbc25c736c0cc6cf273588f7d9b4e6b534006004e706df555f0a438fda4590e69dc8144f70426a682f983eed38990ff6fca5804e7acfde68627d4cdb87269d29f7cf6ea5000d060dd1bec7cd49146b97925172d38ac644917cfda059a15f283c98b4b6b654e464d34dec6efe7706dd9928037915ff14a95183b9ceb5caa9744465539a004307da61ac969ac0cce870967f0000a6714ce4cf1403faabcff548688b2d2955bae734967554a489c7aecce3e34a7686913c1d679bb341f1b1b16ab4b556d8c8bdb3357c8ab817db955514543b5f85b60520973e11eabd9f34600b823b22298364014ec813b24d1f89208da2613f795d6db6d360dc62ac56147a8cc6bd361b487f4cb6cc8f85c6dbbd3a5fe9397376b347285ca10afd92c992b50ff24025b074f5d213907fb33bccef065f342b0c09c9e32dddc318530799721bcc0676c2d86e134b8a19959b4a4dca5008fee0a18c8c3c861b667c3d0914003d283fd7a13ae7b43dad8e1472b4b9e314443ab61d1e2dff49af29d69560c090263ad5db77300c69b9f9ac9c1e21d79995507392ec3c7a062e91950fbf7cf93f740505ecb5470f4403e27c57bfe06bfae77dd50b411d4f9ae8c46936b19cdb86eb3e12ee51c7fb4bdbcfb02aeabe7e83d41b9428e521267b549ccb057fae779798ef379baec647972a845db348d0eac4e0c5e08f0308fe64f11aedb1b54a19be848b8c1b38774e6f522ed6422215a92d3bfa53b19d188977031fd0c22e8cd80245abe4c4552ffc27ac696d427ebcea6e5a04dca88aaa6fbbf62f4c655b0c357bd053b73978b6b179be2cb0fd37e5e5941ededa4b674d6734219203a076cfb88d7b5d980a465f313ff37ab58a09d312b136cae2ffcf0e2e2fcc339f2666a18aeeaf77e6eb0edf32fc62514b1840b87306770eb806db09fdf230ba99faf7a82a36592265f19a4f270e1aed90b2fa2d47c1abfb0524ff5a9961287f1706246d26a4145d8f525cb379d014152d9fb452e08be7cd024e1e7e44277d8ff246b3abae20d724ff23d882e34ce43228a8b434d09e6e1ef878f7a3859902b8595a9cb55dc180f589e60fea4e368bdfd6f6f72dae0487b600b7581be9f5c07281cc61a9d6b65350f210c43a229ceb53ed673ca71ddbb2cd379d4bb041373e49e35307dfa6125e5ae9712925d8cb638ce75ae90a70163ba9b8c3cc18a221e2fd2be0b7648bfd922b2aa5483e6e567e33a80ba6e7f94f1e9044d36ff12fb6d856c72181ad6cd7b54b03b6561069366862f11d2e83e1d0cf1784171248dbd4858ea5ffb5414a19580d207c95a7805a6e4009e6a9d2f88df01cd156ff4b4a9cae2c98642bcdd8f32a8e92a54a546b1e5363cd5e68de84c0c5bab11868a3ea656dfd1cdc67cd960ee6e53d37e39551e3b7aa316674f4cbdc42ac9980d6876e20c6be2a42c74b8debfc8e288613784b398f46b0627d64941677f5120a369336f308d1c62c05b4bf0e5321daccd2db8dddcf38d2feb101e4bbdc143f780adae33d8bf6fc6fe4922eb7c51bbbc1fbcba94a15b4940755bb28306fd83eca8abd68bb9a394eb44b163a2ce7f6ffb3cec669baf639f7f2c89e2fdfc3d2fa5f4fff061c541b335b8806ebe335ca4746e26ffb23652273ee3b1c1f0109e4b377bba1eedcd0a52673864e397338103d8c221ce67dd7fccf2671ed16053aa4d54518e3877e4b19ab43ae1b1b419806b32aad47928751b4ba09b901d092a868e12cda10212ac24ee6748c76369d18b58120f10e7c56e6f618f5c06b1f8aa818b3cb7d81681e700227e91942a0ca838749cae43b94591f6d2487f50cf875882c63864169872e462677e708aa3df731334bfc1b8556b09dd5b8279c88295a68cedb3f619419ecf7d48fc39a8479c87e4b2a9ad393b67efe3010816818ab51befb8d190b400524ca5f7e4c3abdee3bcbcf9246091706c816372926c2de7be4c4a1c3b748b249e131163ea9d82564e03a118568df811ca942e4ae8166410fa8c3766e0c32315db21892fb8984e65d4f9659a0d012b529fda250cc564c43485eea924b2d9b9352a8658356cbef2fde1e8f43a928e345127ccbe25ab0b4861b3bc1fb89d327c5e3ca6ab8e1ea16eeed094d75e896e1bc123e916b8104d9f8958408d330ee4fe10bef7755b56ef33abaa6244a7887dca8bb3653174b75edaf87a220dff9cce8b0ba26dfb039eebe578e79efb4249c4421f83dd90d1403f4a37c1554557e955d958bea8057f9b74123e439d21c504017b3a44d497b940517ed3e6b603a5c5655be02d6e13860b525e7a556a9aed4bb940e147b42193206405da2c51edec87b54ba1b2a5f988395b8e34aa0ae20c0b7bfe9cef0ba3495dbc9081ae2569188ac6259d56952f98137d5262d5c9349ad2d80870325ca1d651e513fcc3ba1c2b726aea8bb0d8d80bab9a6ee3a92cff9cdb67dfd4370c2c85b114186852154b213f6fedc3a49e2c16511ba8d8ea7a42011a53cce78d4ff7dac68e91e44255459f08439b5e2d1d530afb6d0b0b766b9f4d2a8e00958bcafe9673ccb9f4a21eb929d746c0eddf551f7ed7b75c3baebc4a9332c1552382f4cf483980423222d16255e7c5ca8803a5e458c22ab187a97a9e72b61ed2052a6620ee20c42739c87e1153fb2b38a0048d5d8a8009a1901a62b21172d8e32d5e0a5efee332abf478341155e0b837feb207e9569cecfc533ee97dccc223d936253e94844c3672f9345a7d66c13904a86d96b2747e12bdff08586eb0637d4adffbdd1614888634d02224cf36720c3eb50f727ef78a6794be8d5bc85b732b6233725852b7c936a785cdec37472ea902e297b261ffb8f58bdbf57e8a1e6c9628631487a0a1d3c459e967abe8614a85c27e0807092de2f690d427ee0fd558a83b598e869ce65008c5dbe6832e754ad2da3a94a8a0ebf25034fa5462788b7536f164bf39f572c643275e3954547f8ff190b6a6a2cfa14bbd45ca8b172c9e9af37cc1bbb3632196b4b9dc6ce53b4a6c43f3c3927d8cc7ee36c4ed7c034733d63c66a3117fe8a9c39d05880451fe8bd245e03f22fa4cc382af7c86d6ff92019b5249c2cad6d18ada715a7d14e58c06c4daba800338b6f39ab38707af7e2ca67b747f1fa75b9d5e07bc887f345467aad507f3cd1c93c3323843c3422e8ce8fcf8cec8f5b006c6cc4637cd56f8b4ddd0ddcde3496204eb464cb66042344101bb1e6cb4d9b67460cb3ccb8ef14e6cfe14692ea0b5a8668b7a9a86d2f55b5d38a805f31c3a425afac773e4360dec2a0d3090933dc55ad99b81b83935a9efa0ad29992498ab15a49c082b2e7c4cb4ce26bdcd8832316aedbebad42287bb8e62793aafed4e4bada1a4d04a3bc6b91439072cad32305819d1fb8af4a53ddae7cd9203b8d71371870af93bf6f4c443cf64bbae40097e5aea47a21e4d73479d2d6a290079c695701f713a44e8a59c0ea89aee3b71c03809b759390c5df461d95dc19e8c33399a9f0afcbad110e8db94853986f3acd56af43af5f296f78e64cc7057f66ad57b4b1789a5290c8d02c8b2483976b428149d7d6eecacb16b2c4b41114b2830c97824216534d055a4cc6ae7e596f1317f5326cbb7c5b224125d7f3517509f887a15993492629080dd1cf5984126501c0c02746a8a9ab7ddca14968455ebb0fc1b17ec73add8b4a43272e88e49ae7b6638a5be8b91d3d786146f0d18a183fe450f0e7a8b4f08c38cb5012302e035c693f05e3e718da25a01456147b10d1e6fa9278db86796327ef76023e8320aee36212558fbe7a5c0614f85e321e1b9c869dfaf7ac739d6a4dc5cc68d11ecbffee17c7c5554dfef80c80c39689004a8fd0e5ac5329141e60e36ad630508dcbf1f5ebc0bf45475dfa7159b0b25a76e15bfc295bd002c6daa277c519c94b48985527f16d85728615d76dbd4c671ae603b641ce29a4d589ba482c12c922d0d69738685444cfdbf9309d6702199c882be393f94ad671865e4df7d79fbfe387f2dd37e9356e5574f7926bdcdff2a022f287ec92a10313385d4f06b30406e7e5446a553186c68becee8262ba86dfd975973872bb3af2a104d269cad89648c0b1a13befaf6324519fab901dabb6ab3c6c0762a68416857d7a7afd14ffefbf022450b055b5329f4f8c5710322be269c2e5e78fdb00a5f1cdf7623a3b4d605ee8166f9f60f677f3a1a51102879cf745a80efd77b1d90ed62f128532da5a63c187da40a5b32622e46f897649637a7dd8fc5f548953e5e2b6d676c69109ca5daa1495316a38e5ad740eccc7ad7d54271871aa633b37fae723098156c760979a195a67b62518816698ca2b7fe678f102fa626e08d0b5d067c00c5d9614c1e127667511643f57b099d28e135aede722b682907e6014f6e27901955ac33f0f36cb9ca79614e9c2ae64032b6b86db382c0956de9d5d937921f5e4d3fea35c1e0ba2b83f8b59cc4c4af0091417ac21fb298c3cfac58d739f16cf0e3ac04bcb77cb3febb0b2bd849e98e244dbb0272a55d572e8017083c4b05189249b02dc98ab0d35c5357c2531e150c87f2fb41d659ea5c4472ed27254f940e4a37e51a2d9bd61e6b59a5ac4d336f26728031ded624a415b4bfd39fd5cdfd5d46ce2f645d1bd6e772fafc968dc52ec8bbf01fbb7d30e33e11c24ca4cf687fbc23e507f6878d4d5aab693db733305b2d0669601f68ca69a4411d78c2b7cead182ff7b7923efa615e0f77f040ff82e99e59fcc5fa1489a20f6ba0b1c2e13d4725331450c502549a14cb3dbf825ad7a2e0c4822796696e073a691ccdd4d2f21282fd64b89c284048244fefda5551627dd09fe68b5c0d46e753a073211daf926f071fff39bbe2c473d4ffaf65265331017c912eda0c2dfbdb4464a2a178b2b899abc52255df78fe0328bf20308644a8371f1774e0ca422d0af52739dd64d8834fbb66a3f5b2c94edd47b81f2411acf833d415e9cbc7cafe63c4b9a5f09b4db49e63b6dffcc96d4b0cc5aac636785d56f219c9f48754cec287e228b4ac1835c8c949e0d78b4cc7cfbd818f58f96913bf9f7d5acae5d1605e393994120e14ac01ac6b529333c9b3805917dde3cf72d136b513cc26c791e11519c1e829f0e17d626885d84040c7adda7100e8ffcfe148e08433551859b129d3dee956624b41bf046166d972152d23ec6480c706f904cac17eaa7e3996ea09c42638ce6c30a1492cecc5b2a282b8652c8d40ae3f2994c2ece3457aed16aa59c58464279190adf9a8aac9e2bcec5b0498d677cf1b434732b7702f235f26381ebd36260f4d63ed856ebd7754b3ee7c88cf89e02798bcb7f08b6011615f5e0027de2481373e21cc665eb7fe52a3588827f02e35fe6ec31de142cd8fa05514956df2c3fea3be912cf85eb61baa2f0a9bb6da990370a0105fc0204172603b74f01f3aa30d39a7f7d68c77043b6d508d90b207522a5844bcfa809447cbaede0b97d892404cba4393fe6e168ea77e326bc788208cd8f57070c490691a881495f9cde8deeaf55d638ca1223be2eb511803cdb5c61f665d41ed21e41370ac87adf61f4719256b73c0e8f53d045d4d2a324dffddf0cb5015d256de518975003fd176b36785360c78a68239e3f5f638e66de2045416c61f26ec7c9ccd9a5ef1010570fc08585dadbfc0c5d3deeced143a58acbea4cedbedee6566c2c6fe3faec0c5cad3455763e23977ac000000000000000000000000000000000000000000000000000000000000000000030e13181d23272a726d656d6265725f656e646f7273656d656e7440", + "erlang_station_node_id": "8820d319fe9a91017f55a37b38c62e0284cc95f47ddf8257ba7123ed0dc43ad2", + "go_challenge": "a7656e6f6e63655820ced0c73e030e4733e675cf762b54fced6b40f70e756cdf63a87b2c143a007a976770726f66696c656770715f707572656776657273696f6e046a6672616d655f74797065696368616c6c656e67656a746c735f737461747573a26374627358bda6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f69645820816cd5629b43d0c4f58b915f02be3feafa20093ff2b8fb23ff4805748d42cbcd677369675f616c67694d4c2d4453412d3837696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f6861736858303581494b5a7236cc29634f9069cf57f8d760b7ac6d95cf9e8038a4d9fc0b90e7dd28b9b9ae8eca0b4818ee4a25d2cc8e697369676e6174757265591213d9d9f6f25535104b8d4099ac13393d1a81dfed864f84bf23deff154be8506892f777e67f2ded1b4b1c1e764edc90e9085068562c7267daee9f2de4f0c791fc82283b371e0cb3413325286e3beb3af7146549d1b170ebf2d909e58d47663ef2e4a2032d720ddb71b7005e279f7d45277c6a17a4ef0dbc7ee6c375494e2c77863c37879b7900be8dc4b66fb3d1da8bd833369993c9e36a1016a3f877fc79ac4a9fb3aa018db1d60378f5350fa08aa816712d301eab2e4898d43e939c105896b289201d1c9673aa25390c5d9f575caaf77578fb32c0836eb2d9a262265a4665381563452db7b50ffa2a35a88577fb3573cc444f19acb190b7bc67a27d0329886bbbb15903bbd7637dd74527dbcd0714785d3af4d3e17fd1bd5c4021d6b5d546577d09233e752bd6b4e36816e912de666b276ae89915b4a108fb1ee5972e3fd603092fa6c8dda1a2bcd085b8fb2d77862ddf43d8f86de960184ee48b8384249f35d82b4ea409a39f91c8b54f8a69e577708b2cf3c328c77748e63d3b3f92d08736431bc447236542847e4aa1c9b305bd05162bc1852d5a828875594ca1f2d72a76911a10342ed8dcb19df4e4588353f1d09909e17814144a6d4ac3c5c90b2d7f84093e8e0d7222928aec31d26571249d324564bb5115898e4cd988cb15f663dd1659a8009af01ee202523db7b498c89c0e53b35c888f674a6807a7ee444d2677a330113d774ef64d53826d6836d1b77b653e0f806922533c633d48dd0dba04bd00ace4b8ac6f5ea81e44ee9a84e5cff12f0fd9f218bd2541aed6205e0a88627311eb21f4729e8cd90c2e76bbeeb2497a2abefc991b86fee73af515589046614c817b4ee3537af92314286ca76d5a17892805abb61eb42fd85131928a2160247dfa7799104b5c55cec47af5213fee48d292609c1f3da5b7ac2289cf397028c0cc8d0030cc6ce7845693cbfbf1f6f6fbf035e605c6d988d8a05f37fef980817f726d62ed61630d76d938fb040aad829f75422737c8e42653944195be8c2d905b92f591398b5f5b9d5d3165d4201f1bbf2d5d180268ec08e421f4477e3bacea13d612c8ded3b7c5b187d8b40a003f6b14687dedad26aa9602803f11d6cbf5f1294c1f499b7933f11a76a0fe6ae22bc10e84acf1cbf8cfc257d42bc5fa27c4fc0c21831b99e238bfca616f3cf79f2d6f0d49a2c913b960986fa49ea49b2272abcefc21be52fb03f6338c8a19698400603f0c5785369898c20a7a1fb7b386869c6e4b16def3e56c458474f2991fee1cb946a6067e2279d2f6953b6f0c1e013fdc3607d3a78ac0c5a5f4d78672c180c9c371f0bdb5ebb379d4da2cea7f5899e916bfc3d435d28f7f88e0af3137d2db08686f9c007c647ca7175a4189eabf397f23dd5731cbaac03112bfa04a9916a8627bfdbb68ff243540edfce08ae44f058cedea482788bb93bb6bab60f71351141c58d0c5223b0c2690e1d29fe4e5ff68e5addb1b0252facc73684bf99f03d380bf809065fe83cffa297011bc643137699f85ec70694b04ea166e75ea8c9586197cc2a9ec64888004183831071bde0ecd0968b4b4025046868d2dae01f89c9733d43b2d2c9c0d5230715701375ab7685727eeedbd77d0639db02cb7431bd417ed2c43a28392b073422e42a18cf3c8139d6ae9b3bbab864114618bdd8fcc3eac56da32a9eba92ec5ff560ec0638615476a0026e381b29ec66d2314f0b7894c4ca3ea0fa528b6d184f9268cc3925280dd88adfb12438b4af46beb8ba5981af88bfcdffb5a0af24d998cf92930d368634d6bb6ab0728645b42a9cc4a92aaa138a3204e1153f83109f1e9c1b0ecae1bbdc50c9ac9b17fa25f96e1f577a08e9b18b3853b1315d5b7a15edc247e32f0951e4e536cf6f0c9b54ce69efac973bde561fc45ec6cb582f349bd39aa5de999ee1af30bac7deef6913cc8cc8cb643b5dcbc811aba166b8ff8932ca8166e33bec8bd323bdbe72ec110c5190d17d99d36c20ab881d3a0053160f63de0d735a44277761477b1a1c7cc4c80baa28c6be082a6579d0a8b3db57e546d6f30aaa408ac418dd5fd86024e4d0414a8c58e3c8ecabda15cf9b929f69fb2bdc8bd9007d87dfe25178110222c80cb9beeb050cf0422f17f354c8290c6e57178c26c9420473079510bb86d9bdf44879523590c135432fdc573998efb635c8e00e33434764a48ab281b4368385bb80d365b02040bc9fdbca55caa4baf03bffcb9956a7baef7e4e56b9a86dbe5ca62a109d83ae5324fa664a276cdb1e38efd4b1ae699db5ae9fa61bec3f61c8cf220cdaca94d67c5d5a7efa49e5730858b481bea31e97aa6903fc6ce2e473092365d4ffd88b157edbe77051a8de39a4ce92fe4b40e072d45a19854ce3f0ecebfa3049a2fc1366262bc479b33a1535b715b84ffa34892098190fe1018827d05b62d9cc96652fd1887445243efd1174927409a01b542c282fdb8cee9cc59fd2220273718fb7c5514cee73a5c14209ae1d819d002212035e49fcea1aabbcb4851f804b03953e78d07daccfa1d3f88d03c2167bdae3dc9dcf6f836361dc469d7d42814934db22068186d50c4ada609cc46040c14e45f9a977879c8352b7fbec47649211e980ad0277059a0c984996a15251ad8578830d8cdf0440be6991ca189b92bd2de8a1313dbb8ced743d92237d3e496e604f4bb0f72d5eb70ed5fa312b1f7388d2415e8822494f873376423da4d7f8d08e06467319208fc73af8762a7423c3ea19917287cb42b5ad47e7864dfe5c9bcc997a1300ff78eecb45c8ba4c21eb84b3c68988eb330d7c8fcfde972b4ba6c4de960d998b26bc322f2da052ba665cd1e27bcad1b61ec221ad84ae489691e678eef8c8bca91092c1103eb8f6cb1d214d4be32980cabe728cfdd66ead0ba124ddfe321e733a79cef4d7eef2906182b62f3f4244f2da9fc48f25c4fa055ec2957057e18052c0c2051d98e8320b02bf6e7c950b789e26325191563218659b01cac6fb90245540397ef72a07b3e91b4902bc852b9d70d8d19ed567c2f779dd3d6ae5202800c39b65574f312a76eb845ab6b77bae5d504dfcbeae2f792ebac152bab1ffd905b7304815bbd60bb3610595dd5b02adf6b947f3944ce2a2966d1b2b9af2139c867fc98e3b4afeab7a64308058e51e2873986cb512d7d4387ec3fd35fb6d39b85168e2d0c4ef93c4abe01ac9ba382d70b30a0878671a6c6089433a33bae3e89800a5b751d662ca1ddaee502cd604bfabc6792ba0556003ce0d7ff8c43c0b713f5f75b049a0c60a0801fa1fa73eba221f746c119cd3e1c1a15dd0a84fc72272781726a7bbc0ed6f138b3cbdaaf9a5782643ce8ea519b0752653375fbfb135fc38835e5207bd0316cfbc8c9367120923c520259b4e115fb9aaaeb98b919c8cff651f3a4bc543dfbf037d0af63c23c7bc5d16fdcdc73b6083e00a54d71d6297b6f4697fc4430e42862e8b76e42d6eac45f5b1f97922d6e690551df792a286b8ab725b5a787ec394fafde204fcc98a97d002f19bde8001529bd88c0595a3e73828fe853355ed7c5c8a5e64705fe06b5c057aec4491ebd7b9a518cf3b2d5b8049b88040a3b05201b3b9778242d77e00e8526f1f7aefede5f341f8e87d4fc643abbfc2fd1e0b08d1f1fb75e69da277b2d9fcb10ca340ef51ec68c9a2ba39324616f8d84beef843a726c4d7f36f514bab330b2bfea45defc10328dee6454b1d006c96f8a6730a9e9b2931ee35b147dfe244e8d0929c16deb001eedf9e13ead5e234c3d2a943b21b799603a4a026ef13b005974b76f620ec19ca03a11c644730b316d43667ed30fc92dc996e8c37dc62a5c10962463a19301d3a6343adde40e268aeae5af503926fa38c793947f6925b98a80ac1bc50bf84e4d6920ebedc25be2505148110184e4972e81609afba48cd20b3d0575f6c4a29b90401aab2d781aa79cdf72d187fd1523148ce31efc34c2b21e59d7ca29c28a51f8012681c533168ba302be6d93158021f320647feb06fc50f2adf05aa5cd93da76e02ea914e6388e94e048372e16adcd4471cc2e8ed93a95d831c8c4a7a87ccfe6195be0e0dd586adf3bcb2cc2af34a702291a2e6c53f77cab7f51d8d1c69cc5fff892fe64f46cf775317b86ea63ab39ae06fc3ed80f535ab1d81a96d33cd66c4639e2658801410bc2ab122e3d51407c6cebf5bcdc1b45e8a878e4171a18906abe009cb9b03388d0ff1741a1bc0caf8f64f778651bdffc8f6419b61fed39e541e5d2185a08ef2b8655e9a1b6d0807be57babe2019b4936c74ab062b568c4b42d14b497df3738dfad969ed48a8f76f269ccb0ca2479a7ff1ebc3ff3024f085f9f3cb2d316b31b94004c93b8c78c14f34b6e4b939e3cf52a429249cdef14f2fcdd2881b1943211248bd6c62794000e748cd9a6d1e2a5d35146f9b6f2b457bbc8daac7189b04d033a0e71d707377c9f05dc29907c80e5cdcd736843e4aae203b864bb797b3eca3fc01df8ec6893d2c53e0cfe2e053c4a426cb8df34f99177a4c02c5ac6a8a48380f03c7922253dcf6a832d0de5330ac6489f94b8b7f136fa071b0e76bc03cecb2209c3fd3582b7a9d4e1ed2d22e7c9d42e080968e4c81be7d52ad6161608e4a5025f7f706e450075fd443d32430ac37c0f8fec518b38ac432c68a250b9e0d943d7d72fe078076f7a856d3fbd837c5b803fd63cbab19ab522a947a525021ac2aa27a554d51d2ea10932d73976fa5838747ac546cca9e84d2d1e27c5adf95ddb82c92b64dcd2d872c6d7d94656205049b9bd8bd0d3caa4585d2b595ab0e26df991e27202092fb911f72d775bf9e5d1ad0d8255aeb6402005cb2d933dc3283ac8a2649ff676c741ea3f555842dfa55a4cd7608739e5d4d3b944054a27f8451eb7f4b0dde663ac5e51691dae87990799f5ff3e82245e7d351d0f19b0709f5dde4ffe256179f16d45ed55fa9f59aad48d1ba7ea6a50d5b00802a7e11cc42c420d7bae34cda9c75e994d0e3e5ff4465fced67e8396fc94daf8daad4dd64416c3fe431a2be21528b083d9edda1cd5af054ce9ed031c1a92eb0fb68ac531873501b29199ce387a7ef9afe828919885aed12b4335a34e2ce67abbe10b0a0d59bf8acb93e6e8dc88dd225e4a9e65fd836094630eb6ff15bed026868f2df5e7bde1f4f569e3bf0dda59af07e9e9aea13073f6499ac455ecd8e8176cd0bd80ae1681a68a831063e62451609b4dc1cbade8f922006567b9642f5fd1db70780482dfeb31f01afc4862a2233a52fd7bc0dee09658eeb67842545ba7060f3b174334c2f01c5f0fc5d96e773364770bcbab53ce9f4dedc73eb8a96a69a84cb806ded13f6ed6bee59bf93b584c11924cf86e9355bad1b15f016d0215915ab7f2de95a23e15681632476583db11021c50620a3c8eb594296087ab214577021911006c66d3af09a00be85ec593bf8a849693c0be6c5219e9aac0e5d4121a9ec51a08e2802fec28f90fe214e560cee7ace43dae0f7c6dc1be858b72d44383552a9cfd9b2fd1b46911acf0dd88822b43d726cc1119759d4ece34d4cd7a6b6b4727557b8e9e058d160a350505fc22f1d5d64c5727a391426cd82cbf3a4f008ab7299495a044041e60864e4de884f706733f990d379212707ff75e5e70c40e43b2cc60c64d7b0d29f2eb6a2bd01c9b9f48fccf3461084c48ee48e1a0517d38deacff47327a9329e2f2ae3bdb515cb48afd2b8a909bc7491ba8582f176571c740ba2dec1188b979bd9eef97f025f58f15edf82c9f9f12ce9236a58593bd8fdc8ea16625a072935e6515f64f714c9e7d7f68a411c78a46440ce9fac54dfcb971c21de6fc9ea9150a992a790c47d4d620a359ab78c971508ff8c8be2afb3173bd6b21c036e6e853530c81d77433760d9f2dec7a55daa6222b66930ec3157cb259c59b5f173245a92db7bcf5b186a438defa0f84b9de913a8cc805489f3f32d29925729ffca8041e2fb5198daf702bca24184214c106c36d43dd835f388ca89ec586403fed695c2601576bde80a1e30d6268f6a5b03cf71d08e80f1c03c0b77994bebe68abef5268fdb22f570791705d53e7266c89f5302ca0d84ff20c2a0769b88629fbe76b1fb15d0395d2353b47b7bc868cb8ff74a0e6a2edbf2682b6b6f45fab6a50ab9052b9138443b226e425bb12e802ca4985e248d0025b959af263b2ce3ba6c71a580f2fca1ec1af40a99adcd0eb7178f5c224a9f18aa5342811d98bbd418bea6556d86318295eb7e59c738ff2ebab22a3617f679506a1a92e38a41e0c7846e5d0d69e4f9ccdfc56e20c6ce1b4ce7388738254b81e78e8e5a838fec180e08786531a91be4ae008905e7ce18da17c76aff40f3e423074069876377e7e9f682fa59a34b920882f9bff641245936fc0db8f7b52fd81bb5c020df7924b60d3893169aa65ec2a38a0b7bcd8fd0419acbcf82d3b566c8f96010b1f222b6c8691e2eef4fc002162001835575da8c63f547196ab1646b4f20000000000000000000000000000000000000000000000000000070c121e21282d316b746c735f62696e64696e67a26374627358f8a96375736563746c73656c6162656c78184d4143554c412d50512d42494e44494e472d544c532d5631676e6f64655f69645820816cd5629b43d0c4f58b915f02be3feafa20093ff2b8fb23ff4805748d42cbcd677369675f616c67694d4c2d4453412d383768686173685f616c67675348412d333834696e6f745f61667465721b000001a0acc226006a62696e64696e675f696450be476821fcc7c2d804df377b6d507cb36a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f686173685830ba5d003ee50124a47aa0d55c045bb81cc4d8f48f8f2af53deb5f9814f14bd78ea652cab197fc2348547d4cde771bf49b697369676e617475726559121355f9bd3d0e6376a8ce0224214315affd6b74e277951dff425df83067eb26de8d6bfde4ee03bb42006bc4103c68027dcf3648dd3a77eb031b5c00924302aa86bcc0fe29bb486427df7581152893768ca7f75c4277793e77b03081187bc9bdfd719c820d957f9582392f65975087bd40566b1f7797f2472e87c6fba22db23b808ad59b0f9b623c2156a213fa6befd503df85ebc34f1775de1de708b58ca32704bc9a2922a71261f36ed00741f28517a167f2f37ccd5029b3c0a693a267fb15f4051a0cbda4b08a35222f8c5acf22f27bd428f24d09c74f4f405c05dfb7818f6a660becf6bbf271b89fd6aa2cb2113332d4892709613014bd6038b24f916b6d5349c4abf973de82927e22e47a458116a173439f435f70301f9b1eb41f60e8f321fde60c9c222269a36e2400792ceca12d78bbea90e5a0991802c7530fd068c639b50be4a2037ae5faa627621878c464fd6a93660dbd05c745f015ab0e20f4e291a6b3a0bd43458969316c1e4381937740404b8d1af24b80e7d7b65bef1a64bb079eb789b016fed7263b6792349de655484147672052dc0c1b7e9d088c0d9102510fddb11a55ffdca05a53088f10a43b0167d96c177f639e3aebadb4a6fab053ff397ed32dc47e99825fb6aaf3262468d236e51cf8d9bfa87f846c27c52b186aaaa2b869957c6a6fce31e5c7aac913916bb369b41c874601afd9bd653cf3ab96cc904c4509b2a8d9f5f90a3b2d506e46dfa7ea4d4ae31ff625d5d0ffbd89d70a97823f1afbeedd8b413c625f627e6d2d3558199208bbe5fa760b7bce86ea1113605e2fd192e6f0c41e85650ba21185a0810f7228e4c656341dd6cc33805209eca618bccdeacb739ae59b50e6a1fa7f60b47eb3bf8bfbf7505c6d30857988e1dc658ca385f6b377453151533856a5b405acb2e4e085bb62d354a45db1fb6995f8cfcb1fab8add7ed1994418c4f8969ede2af36306a66711b1834a9e988a675060850d1ec4d0e80154df46e581b36be6f6f6c8251d9ba41b42a75080b53edd9af57936e8f81418c2d841d6fd2a966df4da8be54c438afff7056050bbba0771ccabdd90eae98f1af9c9fa28e3c93bba8f39438386c3e117dde1bb29c0615d815819ce2493c21fdfea5bcd10b892b427648f50ab5be252787145dcc17dd7ab03d31f51d64155a4a5a51d8803e4ae03bdfbe897f34246545a6bcf0f8c03095879aabcd3584cd2e2a677202380d05040c3ee7db6c38feb093c69e6721d53a32cdf02dc28e676c0a2ce4e04603497d53914265a747d8f95273209494b448a9b7d30bdeb569238572eb4110614420d2d73f3779db2fe46cf74d12597a40d36153082f4a13ba3bf9740bf2c53ea1edda1d99edc2f380cb98530a59103e27dcb149e6cec2a0414ac89d7b7786fa8782d452126dd0600725318382427ea05aafc42e66bc41f6b22841d23ae16c14fe9e53c3c99e9124b1071492a7f522653565356c3ba33b5cde438bb38f52b4ccc9f78835ba3a01dc58e912b0c732cd4fbdd98fdb3cc0cba774f3c8fbf71ba8f163614851d1878c089f3b78f9ba8086e6032da66daa444ea161aa1288bf6259ff82e2e8315afd0b0803849635f62522241977afdb9d118053ae713238bbe695f353282d1910d0a58a2694ae3039a37dc590f5b41bb5725c7b8e79ef49998b76a26060073ab75946824f0567824386a8a6929896bded135eb5c62de8edf4cac8a16c584329e89242f08982532d17e952111c50440a71e237fbff722976bdc14f3f880dd2d372bbd017b4d72390d88b14d37f45836495afc4cbef07e08c6d88b6d5519aa1e4cddfb1a921b2061e70b631432a98e12a9d219138b3f4bd6f19f82d4cb2bebbc283b0be0bbbbf332e469ff8faa97b826b5273b39b04877776e3724bb0e4e9e1d17ae4c4b92e7894180906dc1739330b05f3c3979d81b41c8491ea2b0fde5eb9e813f74b61120de2cd8336007ef723f6d9aaaa13283caa4f3226ef741170d1a25fa5e1f96945382a82dc20b74d81b2ac86a24f63d419bc463f5af702d39bb183d252ebb7a0133face4c81a73ecedd342b8f1f58af77affb2801bd2ae056ea428af78ff722bf6a6cda5ffeb788ae6a667b583877b33f33aafef20c174d07b10aa5927bf0d6afba8b47187e1e0ba19253db1cf33044fb51cdd153f6b96aecc97c1aff4bd25cff7870ec1aa33d5cbe2217fbe78f6f38e4a5b511d090da0935dc8231fb5556e6cef8d6f793a74e4a15805614ab9da941c3646cdfd1dc038b16467f6270db4bec88f8fb241e7c53e203f7b24f78f4b1f85662f103249a5c913e3dc5da363c0f8b012bdfacacd8b55d979f1c2cd19fc0b8929804170dddd6ae29792d8efba41b157d06f3af240a679adb39eb679a26153ee17f3bdcb33d5e0d9b098fcdd422eee2df113f057861bc3b0f1e5859dabf0946868d13d8d0b8873ba0e655d8fe7a7ba18f9e24897fd5f666db9435b605b17d862acb9972b94cc210be3dfb25ed460da19f0de82dad4d24de97d71a35acbdf1abfd7ac330bf6b909f584810adf46864fbc56cea418120600d69301a1017aa42a921e70e65c78534f6bfb8f525a05297ac4b25bdc42875815328e8bbd24874cc4ed8e3cc7273a7456586982e073a4706e096bed4f775481d5891e95a5583e5c5846dcf715fbfa0c077f93bd6874786865f88b728605e5a932a520aa445bc663142b5f5cf148be621e7a76fffc0a45ab774465c9de1b573d044bcdbcc961ea1fef74d4e09e7350fb98e18784d542c210d893a8e9f52fd3f1faafda9d75c808e1d600579d40b5e9964cd782de5a7169e96db39f8389f94cb4362ecfd600a693964c8def85aa42182affa1832daad2e72776af59be66f50b1a2e419835cc3efc454aeb7b5a4296d0c03cc2f2356c3570149a0cd8c333844e992aaed7c7eab360d586588e3c3db27d2d06f1e2ada51e8e690e4c20235ec0850e2d207c380f9fdd715b671417f4043e210f4c65a69159bdba25e8cb9af8ee67861804b89b853901ab1c4fe4b7c192eb5e2d2a29f5e357bae1af006f289083d5f4924a210b78bdf3cb95f825e91f6acd5f81e47d771115eabde18ac2dbd73b19eba8749f688f40f12f99fdf1f6ceb51dfd054a1a3bf12d2df143e6f92ef6fb1f904a03fbbeaeb5672780ce4397b9a647c8177f26b3dbc5dfad819e8c1fa10f76687dfcfc1f6a10f4934b2f7a22e9b2ed89858a8440ec269801153a42feb7e8bb2b2029630bb9fb40cce79fe17a9f51ec7f209bcd84f2d7aed02797aeac77276d4532b7622d6dbce34c0ee86355af9d5c908aee632860934fae8a5eee6d838e323bf0ef5b6440b6ad96fedf385c61a12e20a8a2a18b552448f102134ab9ee1bf2ded46f58182e2e4377bc117c944cc801bcbb9a3abc7ad49e2cf60771e1245842938501f4b9f7f9200b1ad8d56a9f82aab2eba6ea21b14ae9998379da3aa8a44e1a150d5863735fb06e20103061e3cc5f029f69900fde90720fe469310c595e8e8479943af6679f7c0f74aece777b0180cf2ba7be73a6b458f9bd76f66301e702d888abfe0e38176bf3e86d9824faace433e9a5464ea0559626416c79f1ac67799a98627330ac5080555875e89c6dc561610d40ad9dd0af462cc4d6099ddb93c244c7e34691e60a004e8c480ef543fc6f53e41cdfe76024e1fa735fe0f54225572e937550c49851aadfee3ec75f8a2520e6056931515979735f6d27120c99c2004e56cfb8dbffc4506e49d98a045d0e19139a57ecb24b51a4e65911f11bc5a1b43a7858c4ab1f5bd11b3d98e9becc786998551f4c90238eb1792f8d19a65c837bf8f4b464aa605615ab9a4313cf512ff5348ea350dce20f672026c5c54b95d784f158dfef35f3e6e46475d2a55bc3d1d9c0276672004233d3ec3706eda6843a0515970bf6af43e084ce6e479274a30c0fa4ae450151bfa5778301a08cea75e4a6f6325513b303b4a707d3b3cb001c633782899ab07ef68a864bd0a6d3657fa78a3e52fa4c093cb641acb5984d3d427ce669f43302d112ddcaf619862270e902e56885c46301863dcb4135546fa1263ad2274cc722ac1acf2e01ac77632b28858926515ca0d1b9edd77daf7b9870d5c20b6fbe435881debfedf54f5e8b8e74b9bfd3c0699c530aa12791bdd069277850d48783505d063405df6f38f2a95426570b52e4d1baadac16107a0a6e4d7393c8c5243674318a4237c8c9019b46d3cfe845cec5ab4aa66799403be9ef49adb7036f341ffe2bbdd9f3b9b972ce33c93642261a007a4bce75a6bca2326f5d7f8de3e1ef2009ea0658604317f5bdd190d7e5d1240d9171b76133f914ef693fc61a1c55ec8e84a602d79458dd3eef31f8002b55cff3175aad4445b667e82ad9ae7ef7319d2b2858aa4375c90a139452d09a028526d06ec7353e5f4aa8b95582e66b9bd8dcb234125728fc7344be3373cd15a692ba279d0b14adb9b8f82a6e72440d98027d7a3e9078ed637d5fa9764b9457a6051ae0af460edd2103e6ea4634f9a78f6131de15db4eabf3a28c48781dc9f82103ea7f1f67ce5cc3962284b6d07e4876fa09bd6cec9fdbce547f7848f6ddbc159961d4e2d9c95b7c0ab465e20d941b17f2eeac57b53c33e959b732a4f4374afbae77afc142692ca78bbd02c7300adaf2accfa965cac6f3fe65fa48d702f431b7783fa6b4dcf8bbdf78d85c3f111ef3ea9923c45af497a75c7fc9ba05f18a5cc619aeb1e7cc5484f5ef0a97e3f5e3514f86331d1665c7615bd7a9a03e0ffdb73cb9646fd20d262bdba596fc1666611fbf8f5b74881c364ea0ad5b032ab7e9cc6e985efc712fef1e245caf642196dd48356e477b87c72732ba4c770793fec4997cec4ba67f7b7ed4036b2152d28ca236e22101ae7058544ea1a31fa2ec2f8a0326043e8bb45ff648847829a573b57a46eb9879cc624d943eb91fcd73936d990c1de4f98d36694ce2c06be8ef15dd67aa538f83d238d1b1b76da2c0612e226dbdfb1e9670b44cf48300f8f8eabbab14058e5cebb034f3f8f75b53e261fc3331e1d83e46a1652a4c7c7319309c24f84a4ec8eb4196da1b518a3296ae2ebce0206507aad5041e28b917f23ab615762b7d3e26feceed12bebb2717a205ee57bcfbc3a1191d07323960d39bea8f6843485894afd5101b03f96e7ba59243e78471d0c3c883162844445d148ba76848e18f4e07f8d472a4c3689b385b7e1c4da2da0959736732ea301885e193aadecdd5e57ec3c1aadbda2d12b2d38e07942b8212641b50cb106c0802265686ea9c23c46187e60b23d0af5f54a06f9927ace27538ac8d290cb19801ceb89bc563f34725e2c6cf21b5b2df0731a5514c66b31123160dc15ccc17fb8b79762def512e73a71eaaf96a1f966efa5ed533b14a4f24fe73e8abb1f3defa98e8d7f40918abe51cb30f5d95db31a42b9e615af8af03a08fc59b72a3b2a1ce9ea3c6226255c1adffe97f05468429c3d8c119ee8866c30de32a83c823c5ea951d2addd7766660da7c2de6c695268f63b8881bdb7ed95bc158882841851c86258387bee6cff70eeaf1b47508d8fb067f30512d5ffac2c0621913933c8d4efe8a1cc08dec8a2b98ec11e4931eb200fff71c9c05f31cdcc29cbd3c2fd89e255cd72d505ed85440ddaea73938376221bbd2be18da7d2a410f89f7a92d6eb6bea75756a24cf71fdc3973092b7f1978ecc9f13b7020d9a7e51380b7d96e1c9f6b710e1772b9de9b00de123d625b477160871eb89ba86c17cb5ae54ff1e115329dd959f90e5a5098cd91deda7040431486b80ca0eb1abdcdd8d9176a3283b143f053dd8a9c1f3ea096d2b440993cb7127255ebf1c74252ddaec2f9da00e12dc5de70c168ffa6b2e071607799b89d8c3e1b32bc2163461f9f2a3c4a386da6991f869fbe7f60b637a165521cfa677bc1ae74f9142a1077d9401dfc4265f648ca09285a625db27ce323f0872a6ce4ae5faf91352ed272904a04425ac9d9a24a08107084ba12ca3683caa5bbe9497338af07756abb93cd40d643554c1f3a7af7a7fe3192001cb74cf0300d4459c62ee54d492288505a6be95ebb93fe841673a04cae52e918ba2c06a5bf6321e6226968e805a497f85eac5a1602b96a0d0876a1cfe634681be422b904a5f3f43fac1c599b8684482cc15a2da308ee58c678cac432608b94dd1044462f87b23a196c7a8d3249c6e1f48d66133fb3581f2ea930db7f799fcb3b7e4f07b026ad4eadef77d3388a8a93b9656134acba8326c08754ca02a7d7249d778f86bf679c6c831b9115ebd209755a22f0ee5f2dcac179bc6d9b51efda646c260c6fefc8583e11f636b51ccadd9741b5160e3abc3b03bccb4a42a5b1ad8e9fa493eeed290faf74b9299e1625683c17b138a80b67f94885a3fa8c598bc57beabefa3625531d8aa25887230313d5059757c919496afbef2f6fe08094f537a7e86b0bae911299fe709121d3056acc4d13246697274778d8e8f979aa0ca28468386bcdbeeff38427f212b4d6692bed5fd0000000000000f191d25323a3d456c6964656e746974795f6b6579590a20a274ddc0fe236b3ac265f1167cc3da2913027f51b640d51d83a7da806cc660f6d33140dcf51c4759204d754decf3d8c60bd344bfd223cfb85b76c2d5260cb359997bc98872e5f85015df81831a9c9406e30d8237e5cd7c1f4d54e3974ec05b06a43215a3532e5c1d46868b29bd7bfd42e3f0469b5126eebbcf22fa08a4b7fe2764b7b75f60e67c520e58f73515c27227854db302e7f1577abd780dfd1149f524eeea2d563b08807350c3597d4de6fb6596be6fe926416ab02e1d0bfc53b4e823ddad6d0ff43a94ac9477507c7964a79ddbdfe5106b9834ab14581ff1e7166195e8eaf868c39da77b92ef54ef6a2abf23aaf8e7af477e306959976cc7d218dc16b80fe772bef8297a0673dd04bba332c0d58c0f0d885d4accdf10b30e611642e45c40b406a64cfc1857a1164935535cbb2f1b2ee13bc5cc57f28a56ebfb9d984f68013e043e83968663c1e48fb3d76652146241b9e68d165d16b8f80ddcaeadb5b8e322371040a926dfc8d834a710608d6941e4f0eab9188bf5f581d762724aa1cb5437c09b7a0073e825b02dfdd378b4a4ede82f93b05af6d9a6a2092afca2efd06352f91eebbd2400611eb5ee0dc9495dcec12003e06638e32850032eeebf0bd762a7a78a953d23710297b60288cedf54cb06fe85d4e4e875cd2146917e385042ffce95b679e286c28386c1ba98fa4f146b5938f9d4224c0adb80675201ee136eca097122aa7ea0746d31625741dc07cd6c4e20610e9c1091c34fb8d3cb7fcd7aff9b1e0283c3ed7571a6f7a480399370e5c910baa4b6571d4a5cdf314beb288d62ff89235f2f5e1caa3e7f1992f6c7bdea7d977368a192d503c0ffc244fe252932cf40c64842549c6b0b29b16effb027ddce33a401e7fcd90bf776f67e7c78fa6a6924dd3d05cf25b26802962be844ec6e93364e3d6416bff629cbbde36fa627e8512f92b9e31a5914d6fa825ec47fba85a8cb0e6475fb02195b008328a87c4ef74e607ccb6d35a01da103b6989715c11ce00a601af09304e00b666a6966a3f1e768aebf13d2c5fc9c7ebb5eb54e2608db27dc3babf1856fed23f832e50956a1a3c7a961191da56e9aa53a007d97b4624f6689a842906f4492a62d828f47229b87b186c558410cb101761deab3ebc4d60f9dc3d11fc8776e8b0f1c81ecedc58753db744df55cec9bee5560d4d2656e457f298a3245f7841081c2d3b4cecedb2815bd9cf9569bc197b788d818f44cd21d07b5f0eca970171fa58fa9b65c4b080a666ecd110979884fee347561eb00ad3dae72b933012da8b2528cf1a743a7a5a3a9c2a6a09d0bdb64afd3972e4949c43d97c6906b5921aff185c48a893c1a11abbe848d020a6fddfa92b7f573378f2ef00e668397e8ac6bb19985bfc55f9e66e6d29abb7a247fbf7c98a863846a69f7870ab5c602131306369fa104e814693b593a0a32df07650e510d6f5568d0126037b0fe14e9be1fc86e70de02db729be15c3b3c5bd53c562970ada633d711dd9fac9d2f089e4042448589c545c3efc057d4f0a3659a72abe25e8fa1205e6c99c86a7efcb027bb1beb8e9bbb7b5760cf99c6f2b9ac9df1b326932d0aa14e733ee34154d629d3a01ba843dcfee2f399670b0840b95909730198e0b3677f5a02c0ef4485b8fce3558b0819a5003c278fefb5c4788fac1d0ca888da6aa82d8be28692212e06aa481ffc3c1b602ee47fe883c93defa79e6b33b022baaebde04d45107bf3adb7477aa76166306313c0f0dcb75e165ba36fa8b05c4cedb36a7cf0c20b756aa4ef7ebd4f0c58465e88b187fd0584f8ccbc60c168e48515f24648a06b81eff73cad70b34e797f84b8679bdb666ae8547d7077c323d851005800b93242d9d2d459bb9eb22caf6c48e642f16bc26964bb596abab4839ceb5d3728d11271dad8ef0c7f8c1db9f628a8b22fc52c372d6499cdf9c206208189d8bb5f8d6e02849fbf9f8447adae570fd1beaf504b4f4f3d7419cbf60d8c6676d0b10960047e561ab9383091c2bedb0ebaa95b0d59a45625f4791083a90e867579db577525f019adebae7665d983d5179c6eec0534e1102923f22d688973e22c13230487b8488bff9c056953475556b3c744325029b57d34e3fbb4ba536548e224520c1c07d6f3cac9602b3dda3151da41bae26873d15e2b2bea9858604a452ce32809a75b62acf6c9d03e9367857050a2fd82cc58de5cb541df523fed9bffdacbddbc6109b31869b55c45338cdda3e45d519634883f94a86477a730cc388337c1b2d0b1f9813dd53b46d17480e73524552f40648a2ece4b7a14460f878d67a20958f1d654bdc9d86d7a4bc074aa273a45e45360ad689fb9b1977a647c1ac9f700690ac1de9d4ea883edf57f525cd2c84aacd89cfdb29208dc6531fa6025f05826206c8dcfc02feaf7b5ca79e6779c31c0a82d87de5cade3ce539fb89d6b5ecb779ba6e85ff5ace6040b54c11f1e24316a80647f943f94cef449d4ecc5fde108146ffb28e111ddd10e398a567bbce7cc8696b93275f350faef24df362eda0bd7afd7fa51b2f4186de8bb9066020ef3b12d640c347e05bb1b5836dafd69bfa88513d2c2d29f385670f82ab2ff8a10cd8d1aac805025bc38e52204fa8c84304455b79cac68b02bcd842ef3be06bbbfa68e788c9761a982e88fa688f0c805f1838a1b5761f21a68d9b98f004e06e28ba59ebbd21e52b8c492f7901e38ac49e86258623d2c2dc11493466857e445cc5417110773badecf67b0102d496fed3190db11ce9dd8736f7409da51a5ce83431f464084948a5c598a95f901ff6338509d602b26ae1120118451c2025a91557a95f40673248bc5a6076bfc5282a4caf055a3de248e39e3179087e4277ade7a6bae136aac175f65f3b6a77932076211fc72c5258adfe7247fd4a9f7cb9f1808eb555123cc1f300a8db20039e6084cf00102a7d6bdfddbd72ea00be15d33a564c8f084329ddfe408a345df2a39d60ca447b2aa72088e27a1a15e93e1451bc2aacdf6d516426a866c83c036388806dd9e465a2c43affec670d4478548b18125b6a815ab870c852449adcbb55096a1688e69b0c870663348b2a7869437a4316942880740ea7e485247173bc1e798bfccc8ff214881abfe4dd7b22307c5a74a4a8f64dbb406aeed818cfb6b71cfeed32efd2b9f4b429d1b62e84eddd71a6cc7ed45422492b97dd3c1c2a49e1bd9b8baea630bc6864f6d2d639cccb5d96371c7a4be16bc8959b4145c3e90a28bc12ed151c67ce622451f10e2f7e4cad54c22b4bdd92e07381bc745abd9f43c20797bcdf894dc2624e3d32fc77afe78c934dfd8966e4315891abbae3fbc15b04d5ff1ba0b2601bea6a52373c86fd7f3a9238713420b555efd6f04cef59b9904e7ef94b689416861f0741f35847b21333dcf3a28eddec1a7738683732d5ce176085286b6d3ce58218a652748e63558d54213b89913afaf5238aee1a2060d3c91904a36f86ee39b16df7a44bc78d64a0fda5ae7da63d0553fd38b9b3f207565f41d0583c6c645e3f6d065a0577ab016ebbde739810c4b1d7fc237279b4d9221157a66e5f2d82a5263a0f69456d626162e7f4c87a4a99aa2ac9dbeffa68a67cbddddaf0523379e4a5e2eef97b74ca6a43193b7b11ff4bfe2dfda57316f77b339a31d1e", + "profile": "pq_pure" + }, + { + "erlang_challenge": "a7656e6f6e63655820d801a5b8a818ec953e42731a6662fb78a7f7fc2eb6fdeb96a6cc11b0e92323436770726f66696c656970715f6879627269646776657273696f6e046a6672616d655f74797065696368616c6c656e67656a746c735f737461747573a26374627358c3a6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f6964582052ed288e3ff39ccbcf74b438b779aa15e3a88871c104d578dd338486fef6484e677369675f616c676f4d4c2d4453412d38372d5053333834696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f686173685830e5d1953d9720be28533073c96bba3064632ffeea770c1e5d479948a8bc9bcf21eb4a32970cbee1d88235a530d316528d697369676e6174757265591413fac6c1d54c52992ded2b540c6566f0496b50cb5338da762d1abd2b89736f71f8cf83282562ee6a40a2728c7206eecc1b664adca3f884d27f710bf95ec74c57482159c9c8a3c5fd95a71f48ba1687d88bdd7abd5b76bdf79858af56f873c06878317a62eda3a66331e0389b2536ce011a52a3602bba5955f295c142b6e3c1cd4d70c1850bdf801d3dc072c2a3a197066ef695aa9e39c03f4d0846145626ba622b7d553e056b39b26cc328762cf81a464fd871e33589845cf5a390a1936a1c2fe6b10feb3c7184966604d4585bdf3dffb468d040516791837ccec9f861e6c18c1831c33b635bdfdd52842e96f2dfe466bd2468d348cc47edf92901b606c9df50d8ee338110e3ad6d8bb59481139ab8c7433676341356d0dbf0dfc58aadc1ac42c2dbf71453fa2bbd2cdb6740fe43e7fdd23476c3044bde64f4813b2482da9153b4b7fbe71067c40257fd5b2111f052cdb43d0863f342cb7dc4e1fbcf374955c27d584ec8fbbbd1c6f7e93d3eb1b336c45ac887cd9e24475a4a674f04e0e506d6a80dc364a16382d8962571821e8764657b728f798e25ae89948beb63c8daa7ed74076771faa758038033ac280a6426089a6f95691ef7f1d2139e8dc4742165426445df2656fc765ea79cd3fc81079b72663fb2ead38b76f743b3a7246eadb890e3d48331b85ec74392d87b3db00d595c936ddf3b0e6f8e4bb0ce576588954a3d649223527e3a33c4ae4a9946f572e578a99c5930c61cc2342d3c35b9a25427637aba1adeb7c4bea7c8828b5f7cf08f0bfbf670766353ccc33935684829482a077326d7ed778b3aae6ca12f4e35f3d1868e69dd52c895985db5c1199fa716b04759c8f2336f55d4d68f501a3717ad65d4ed3c29de69ab0b644bc706f42b0146078b4d640c14e363308b1542ed4f5bb1a3674a3c182d08a10c75a726c40e5be2ee6dc05efb4e03cca62383dd3f47c8c1295608efac92bcccfb8084d435380627e3684bd96211690ed0cf4e6bb1a1fae6ca3f3c2195adc1d447391a38e2ad3ed4c20d908af61b0178d151bd70ff44e7a6201c6022ba4185826f399cd879d268a3f868f2c3b7ab08465966833a43552e25765c5de218a28fce0f7ebb2e5c9ddb2e47a30a8d3416549a4a801ebcec70dd44a4beddca124e6fc1963c6cd27eb2ad5ca74038530dfc64e4e4ce1caf01d034c06cafe2c01751cfdb0ceb585426209b7fdde2814f67b479a329180e986863db034f685f8b0516970de6ac944a312548d775b6ddfa21a347c3d7fd5207787d754a3b74d51ffffd4119adf01469800fe901c30aebac80adff36f316fe5b4dc3c99181f472d9c6071b434f5380c1380bfabbade5ccabdddbbbb088140c568e002e564a4327130773c67bad69bcce15ea2d9d9092a498c6dd36aaeeaaf9253252088892319c8fd2a2fbd542942039af02c66d6ba4b89ef8bc567b5665913dc5975248179bf37ea466b00c49055161eb406959feb2f9b38d4e2eead02531069896d3303089171f4afe5c29a3b66a120a665488c824c08d297013ad6cd04e5a96e2315c7f40bf7b5cbdd9a4da96291a45c97d7da52b4500af2899c31b14d55324b50e4c27d335fc981dc98a88fb13f1bc1222187e387cdf4d64bcce4f9f51b25a513ff1ecf0dbc3d705457b5db04a872529b70d0b4cb88d1d039ff2a1058295e398f9d52dfeef0c9aca10546068e71cd5dc30c9feaa976912b4201d6edb8eb4068b4e1bab96928cdf04509a50bdc5f37a6c641be2bb9e5e474bde62afb76db996b89dbfc76522a340f8182f9ce0777931656cce8872d7b9c55ef2e23ccd07ece423154ae486415217da9a564c94a3760a06fcde8fd43afb0c14a2411d82fdd8ea7a3763a65fb57e22d49a5e49c1fa324f8c48e79998913a9bf6416febee5fae253440cebd4af215e15409363bed4a2351b99b6fb1a1e7c79ef06ceba2850b94a052f9bd07f74eec1be62f476eeca288c9138553f288aef597cbbc6bf17d6c1e65a3869f6421ef5c6c1a66a0ab84069397bd30fa8afbb8c34bcffaee881e6d9d633c8116b32257c13b9a3bab9637c8430dbbe4b6ec31604c59254481e453f77ae40de68165ba0dbf91358ff70ecf71afdb65e25f0f671c25defd2654b07b4492ea85574214e5a0112a268295a5d387921b5a510bfb3d15b860f6e1107900faf0fb89ef9098a8ff0ea615c08c4d1f77cf845ed0c273aaafb9319c57e9d69ac8deba77d3fb4f2cb49c8dce25465a9399ac5adfb1bcf03a124fa0b7426c572ccb39d0a904b8f80710512f8e23442fd073bf1efb83012cdc054e33f01965d7e992c51452b1f2e059da2dfefcf58d785f72f9c3bf383db3bd7984818941b45df966d6e25a05a78d95b9c5a3cbe30ccab91387db9c8df1da4930d9886d8061b479e31407358148a99c97a95b83794e6ca424547a0a03dda365e7c217b336ba8916a8b24432a7ddc42096f33a61102338ce2e33bff03ccbd663b4afa1fa215a65112f722a0208b48642fdcb85ab043f2520eea023052cb4bd01614b25c1b8db4125b9e205f8219e40cbfb74cebb4fc82f46bb4b7d118ab5eade66e9942083595be6d280f5096ec45c0be1c7610392592dee3be6f1a2624561d27d1c2cc51abfff5970051fec2b12ea2a1c7d935265d43567da20107d2eee46cab7f98b9284ed2ad30a681dbdabf10f24aae27409fc9cf96e81e06c62faf130f2c17c5c761ee95fc65be15987c45fb1f570b8f0d07e7ad36f2d609cfd0bc2631d81065b51c884d63fbe1e05dad2c4306de8d55452e2fcb824ee7756733886a865265c8cbdcaede19a0662e9d5721d5a117eff66ad90908b7aa562d92bb3e8d3c416207f1dd6cac96b3796e319ba2cd21755153e50ebbf0ed64058fd9d88092d661a9f23b8b1a58e56f3faa860e3c7ab40806a48017e66bd763fb045bf94e18bbf5f235b2b8a85df54a802fbab675d5203acc3b1433d3f53f170c77a542262618949eac0b843acc24668e932718429ffae44a3db12693a1cce99e3878efe947337e998e1f874ee2d8c3ec91a4d7b14ac4c30b8f406b1d980813e75f80633484e7a1ecc575d6259da11bc9d9063fb53a691aceb5a0b470d694be2c37063cfdbc2a9bdaa743c25e7ea1d1af756d8bed89ef394d017731388fab30764bbebbf10dfe7d0200935c67d153e4cd8098be9457858f1b6fba21c00905191a36be4ca09932622ea48d6a5e96ca6382f09c801118b0e919aa01ebc5156d282c0e6ab5af7cb3e288a01923bbe163bea41b63b03e6227b2506b956c050bda4011f1a3d9db26295802c3dad3c1ccd481759374a842aae6cc42d6e981da1feb7f1c7f0f1c50c8a8793c50d34dbe0f64017cb62a4d833cf802c3e6cc2224fada0e62bf35e277e9c0e4ca4cafa56884d935ca33a36e6f6d7fbfa081f1fb6a2c8be87dc65c54b001f702f5fd11249baf8a176cdf696a5ea88983c901a2a8a76d3e98d7043d24733d119930e1b6216ef15abe9b51f4d93a61732c67a32770fbcb08f8dc3840b8270f4f43ea9d17f2d787070b90fb34b1803a1ea884588d050e06fa5b1fe0236f89c82d7d34476dc0b9bbe66518432f67af2f27eaf470af7db978a85fffc6bcc92a2286d5b83ae292f69f553b873c24fcca1403eaba0ede72013f8bcf345fce062c6d6437c219c4405272c5448a75c6c1f65565bcbaf3514a098b47de2f0e90973b9f5131113d5b8c7ca2948d8ea0b03672f1f1aa3c19e00e0615d1b849792ebcd4013a73757c2a728a94bf464581a7fbeaa91588914b06cdb9ed52831f3fcbe4406a829463a319388d5464906a84fc32f2f85de9463ab21dc41d5c51eb54e6812f4b3db5449b7064eca18cc524ff18ef6ce725a8ec948f8fc788afba410d568e8c1a172280b61c90195b4040722bb3ceb9ffef5e75082c0e8dfa5ecd48a109f23691973d01097c8ba71e741fcdd573492ac83957744a14e7ecf6fe9862534b418baac3b36c9a663629f000da9fa4b34c584d6e58a7db6f68f4d9f210c29300cbe7c9a10f0fc22e687a80d94c36961e9925fdc11f09156ca4ff3b312201b57dc0e71185e7e9585ae0dcf576b14a281ab1d372992f4a2390f807606d6e56319dbbb23bff6fc075a4d69ad6e33e40a908fb79cf8cb9754f9663b01e39575b8b0331a72a6c2ae42d25af1948107c6c850173f028fc9541fb2d45f6844a77a2395ae3425ee466e969d43b61392408fa4eac1f8c8fcb80169557a2d5a3f0c45a2b5808bd5fec071dcac4912ed4eb67f2fc860772d6c2f092cd9df2f9398e3542aa13a621a884492d810bc98ad4e23d9cf75cfcb44917d098c1e8eb630ae23b9e5e983b6f0375cf1cba87f0b8824cf228dad387f3adbd17838f6e4d1c88be377b2c724ad316e128ac6b72e1ec9e0ffd03b76f42974d0e9da0bf103aa723128fc8cbe4861484ca8bce85e973baa1ff8ef64ac5a86702626c91a69cb7dbbc6f47ebdc63805561e95b3dc28b5e08531513e8f74001ea52d923cb7528729decac5e7424c02e513ece696842c54c912a06ffee9b3711eb11cf4264f000fecf6bcb10f959eb1f56c21b113c99ccb96d9061b094d8df803400046d8cf83821e58c0d305e0dea4bdb15e50e449a6303e33b50de2c1ab8269f1fe14269a1a7b7a0660c23a9c8112f8de4104c4009f8af89987154134c3decbc9f1614e5986a9a277cf73332909c575cb3ea5edc87947850de9cdcb1a8d8b124bacc8d86c923a7969a9403997ed04cfff1e9aa637f2f7da63bb948bc9307d975b7fccee9196086cd2dfa9517ddebc3374987ec9db91292853d9fa1d93f696018c9f4f8ee508796e55a96b2e6d2350853317c44867dbe43eb32f9181b23d4d21a99bf4c80e4a12453e13bf7582f6b88a18340d4f63dbca64ad75f04065e72bb30f8028f735a1da0d4201900186bec23c85e9710a71b48760a7eaf27ff3631104b526f4c3003058b6cb754cdd58cb6f8fdeba8c7b94e2cec6cd6222c145c0c65e29bd6416fa86ba944ba5258fabc3259a5c3a9626c8d218001868c3a6528ce96d8f1c49a76b0ba2b1d29709fedb000fe4a3d22f9fa4b919f024362df14848e8d26c2193394341b72b0123bb7642ebf186f77dcd82b64f0398e04b4de33ba2ae3388aa51123d7c6e789c8f922d9f7a699027d4f126e67e468dd9369f48575921fdd4b2ad8e59cdf11aa919a8af161adcaaae2aefd0f7a92c525ee7e24c46fc0dda857c19831f653ba2aa3548f2959e288ebe44548503d833db6f3c503da85c07779058156b4c5ac247fb96f203909c922bc67aa14c95bf1b6b396117b5bb0f5b845d07d7d3ce25e642af9e7d87291b150423871ed9439bebde6f04dbc40061505880dbfe3ac183ce9428bacafde67b6206d74454c07f3ea1b08d51aa911c38c9ca1ae24a02b2e602342126396dc025816eb8bb72320cb0ab0e47ebd4ce232ecb106ebaf9f840836d734402596fd88625b821fb9cb2bbcdcbed9af3d6bc77990db262479c9bafadc9a8a72f5b61554ab11aab9c9944531a73ad80df3ae869018a046690ff9aa328f3b2056715a04f353bd658a05ab39e9cff01b026f8bd399a06e6c3a9d74a873c0a8d944a1deea6c1d613cdeac00b20c10521324f54adae39d02b9ea711e58b6c940899610f81197f0a8e242e86dd17cbb1a0a22cb48b8d357d4e7c0168f30f414fe096391120c651f006e932d541602f58fe3ed72f6ac64ca5b43da90a8609d519cad491ce1088cefb739c5d1d5e275d01c9a61f11c5411b2027e2fd5568fbe1bfa1556c210de8ea074ab387fb08a9f53aeb0b44af72b866b6699b32981d3fc20ab36d7c6871e363da406aa60ddb2e24d0fb9c0bc9d842f4574b23c32dd106308ee6c181c65e1dbedaaa776dfe7330234995fe2be5b8a547aca369de9b7aee9393df5da23631401cc8b4a8ea6a0520bb79c4b83bed856e6232a902d8cd643957e8ce0cb325fee2ea40f38d417007c1a91758d33842724ccd3bfad1bb2a71f8d30e5e24c5f67ffc771b2bb07ca75e440818e1a8cc4ef23fae25575a7cd6fc9e8fc4390ced849827c68c22b3955ea390eed6a86feb8c7ca8a43c16d181e3ac8758e66dd9d4028b18558d45a327804bf93c7c09ca5efb253c61d5d1cdf204effc07a272b7a2424e0e12c53fa6febb0141f678cad1dafd2c823536a78cc0e2882dff2a87e66a666d0c1b2284b992577823e2fbad69b91cf3c7c11bb5e4298be37c4103df2b84771b52b88867751d35f94744725f2c30dfbcc7edcf8573f4c2cc9a8055873d33ab2cdec424e5a7218a6714c4d253ba3055724e2966c6735f45279466eefbb6e18ad59590057f16e68f67d5d6c312e163e53ae2803af48e583602d4594cc743ea21b961360b5bb81a7d4688e071bc85da481729e4df7d974286eeaf73b91b03a7da6c486c8508fdf52b111b3b4b7095c6cfddf73c626ded132451636e7488c4ea5e718d969baa285dbaeaf828494a5c5d679ce3626bb3d4dee42c494c5372a0b5e0000000000000000000000000000000000000000a0e171d222a303875e6bd8acb58b4c89dafb8aac5c3f2147a5e7fa11a5db2deafac754e3e5f2d0d593f25a6a9b6b6a661ed86f6ff02e98834b8c2c0346e739071c5eec61752261402cbd1ef2ee87daaaf35be603dd567f7a25c5b081eafb6e239cab6c7292eae5ed61a3fba17dafc96dcc991dd8f638f2e6cef6cf2a4c115fbcc4388e44d3f9c02e7dde45fb0958d2a3aab114cecf04d34d4768c5e966e59d5f9cd57a211a8c02f4fa35990ab57a127e719661f05f5f04d13b4c00f55d3d6caa48666c8a19b008308604bf23a21ff79c8b197eaeb841e373f2f617864dcba137acfff711abe9b7e797efb4ca83a4a8f79f24c7d0619be5d387182a6f10ef738225dbc8274192abba963604264ce168f70db8076147a3ea0b5d9262b44de05fa443496cd13b733a1adf59136d9b8dce8f3c05501195fce40c67c56866f6d4a6ff437e5043fda8ace2f4aa96f71f9a67edfae8831e989e5729b9b537319a12b1878ac7da47458991545dedabaa96a1402b88eaeea5efb620ac9d37e396462d82dc71d02a15a549c30fcacae376f3c0ee745cedaaf416bdd82396c500dabc92a593517215efe834c881e0ee49f9aa8797c2644b81c9b66bb9b6e2829fcfeb858ce89db00539196074f47fdedc0d9370763701681799647cd5ca691d87bded8ce58896d947afda988b720855d31832c5ac551a3543036203b591dbade5521f102af979f257f97f5b16d6b746c735f62696e64696e67a26374627358fea96375736563746c73656c6162656c78184d4143554c412d50512d42494e44494e472d544c532d5631676e6f64655f6964582052ed288e3ff39ccbcf74b438b779aa15e3a88871c104d578dd338486fef6484e677369675f616c676f4d4c2d4453412d38372d505333383468686173685f616c67675348412d333834696e6f745f61667465721b000001a0acc226006a62696e64696e675f696450dc0762e962de981a26bd39fce96f09b26a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f686173685830ba5d003ee50124a47aa0d55c045bb81cc4d8f48f8f2af53deb5f9814f14bd78ea652cab197fc2348547d4cde771bf49b697369676e6174757265591413a1b437e4840e456fff8fcbcf89cb8652d331ea16c6ce7a371102d91c8cec54c78ba054391d32be324131112c0c1ccddea665412a4146402756b21637212bb9ab38094adfd5fc1ed7680698a30550b9a2360be9c1838e636acff6b0663d528a5062f2ceec751399bfec8f7bb83b159d771236487afaf989714dab98b812958398e7a747ecedf662ae8fe246c32ebb78172133f0cf35530be533828a138fece97f35cfeadb39d693929dca96cfbe9adfdff3433670cf6b5b4aef755e36bf943a8ee55711e8d5b1ca52f7903dc0482d6b598777c82f595552e296d6c6037aeccc45b4f71c6c687dba21c8f6b41066ca0aa08b8966f066446e8da4d81790e2df0d4dfd3795ec288b67e1554d27987fd00b401bec3b375fc3d5fce605c297330f3197b53cb69ac53eaaa606ac31dc3e194389879b28d2123797b106bd5ae29487fbf8af70a75edc98d0f65cc8e9945d60965b9b80f6084e7334d4e8da193d273c524beb01bb08b91240c8780ccda4f16da33798056ccfc734ff6e7a484bf7e938902e4b90cf2be0f512a5fe251408f487c39ef75e78f2b714c88dc969eae524c18d1a1a6d2de99e1ab8e862c4afef4447524f802f668bdff6380ff75147bdf331178ec902519c186c93addf276ec521bc7bc199d51e4724fd7c2e6084729e1cbf9129c5778e68afb16134afd3e3f54a4340f9abc8e087a929f69ef1ed1b6211de8431c306d41208841eacee718770da64457f0b53a44790241aac650014fba639fc8964d4a2da2ed1fcad2f50cd88270eb4cc4ee5ef05c729a2e2d6018802e3bfc4d69d1404cdef625f2eca4c2bc73cd8b7822c7a53645a79f33f2691d41aa06f8f1780cf600c4d5c3321fbc814307fc913473a0f73ad5e2472fb66d145cd79b783f6e8ccc02b7647d8cd3c299df5d474eb34d489d7641d2d143cb6f7678d0106592893bdd0bc117ba246201ca185969b01982ba06f6c6c729d6a69a4f0ff9658ab92b9c23590dd36b563a83cd6f5414f2524ad7cfa4d8f90577840fbe7794ae98b5a7ca8be32e37507613446237f7a1ae2a0a77b71384a7afe8bdc4ddb670ed92040517f4e8956ae22a408f7b4ee6d35b31e66526b09e9a31decaece6f57f9fdb578a520ed3a27eff644342befdd55f0a366af176fdb6c88fe0523ea9265791597f17a8f39f486a17d515cc20f33e4a499db34c613477ea3d1a4cdf5572e08c0860378058d3c2117899c824c9aa1e39a711e576f4d387fa0311a23647f3e44b1a6a7b9e1eea21f8d756e7865875121731dc7013663370a6b63439b6b062c1d22345a5be054aa21deab3fe83eb5230779e7ec51b3ebb710636dfa5b4e014bcdf69cfbba0dc67b8d633cf04a8d5ffb9fc9d34db666a743f64f9170dbdadf9b6430b9d15d168ffee490902f55287e184412252ef9acf0ca7e45a80e359138c1ef827c92037475acd3d2882576172bcce5a8a102bb519ce18064b9437eb7d102177e985be457e6b9d71c6d0d9f3007cbe6c3fa7ae676b68859c66d94d46fcc41f8fb39802184fdf5fda144106f190f6a7fc6b594c32cf3a625aac1b2768aa1d4c714ac2fb387bc4621de97114197a49c8baaa01d0e0944eb5b623e81db81c2d24518150ce3cf1cd461fc93280cd30f07a2213fc4a4a578a3a2683936170f2aea18dc7a5bc7cf29baf50e2f68b49304481eb1ac94afe82ddcefebde489384555ed437eb69b69f05d58eef69630f620a6a6072ab27f63e4823cd6a3e53b7afd56bdb4c4ec7a7cf300c17e86fa858bdf72284b86f6c8637efbedd22e31cfe0e803aefbcd7a1c7c491fc8e71ac70d4843d356c7aae4a58091eb7554f0d4ece1b7c2090b8ff950bf82c0dc46dbedbd01754aff476ddeb4853813c6f94e87cdfa67432e5c072b356aef221785ae968e471ca38ec6422f12b09030e56734001b3ce9f440baa44a4ca057d117dd6051b2b587f084c7878ece13eba12534f00da8c5e619fd3af261c996c72a2e36df1293aed9d30edabc1d9c05abad664935ac72bb8a3eca02644709c7dc9007d38a63ba6ed4f69c56a6b2bf12345ba02f58644620978fd63fa2424ec85dcfad7861f492387bd05ae18fe660d4ef78c2212bd56b7173a7c0aabe7cde3b498a26223deb5081cc6da82d4d54bf5e08c508c5500710d60c48c60d07a34dd764d37c5a8b0ce31a9177c21c45a28ae479af8b757609753c33ffaf0a6c3d23090ee711253cbb9e4121a77327c9708cedef4e4c10d4232445d73c85f4a771561ca9b2df14b14c3e9e3ccf5949379002955189bfdee4c02c98176d8e70ebcc796abfa610912abc442ca9e530f38484293de86691df332a4818472e76f9bac27b15381f2ac0d07a1485c4fa1c2a5f2ecfce3d0aa0676f4894b96ad6d1fd6ff0b97f0dd58b73edb15dd4d2db8a7f9300e146d873666585b22ee0a67073181813beeec90dae6c178478ce667800e62e11076ac16030e8b2fca8545e7c977723c6ed8ed8e843d7fb3e5edf0d91b23bc054efb7921866416cb762f6b46f3962bf2e94f1cf78fceec3bf025f1ab5f4d43b20dbe69fe4a87173f60a5fb442c9fa8964540aef4291449b67dfcb47380ca8ee79d5b50de4c5fb09379e03410c36d44b3492401f56d9e8629d48c14e5904fa9e7c76185a964511ddc18e131b86d4d64f8c2e7f7636bc051f0ec8af2306679068dc3d898f64dc09d7d59595fb7693bae5c6e3eb04191f476d30f2b83479273f625dab044457161709ecbd092f80ee5d80d865852791b0e5f3441ccec3c933ee30349936ec62cd4144f6fea534e289c24ca914d788ababafb0df90983dbbf3ef308ad7bfb25216359311944d7b6f5ee4ad8e27169ee3d8790af46f8074627e0023b3c22b8381fa5add86f4d9f8dc7cd490a9854eeca0af49efd5d9a4fb2e9c0417ed47dd109942c5f54a35ff211c0e481554e3345de5224c2f717f007b433ce39fe449ee551f6fbfffcdb667c3930351b99277e73afd38366fbefa95ef0a543b89bb7ead49251dc499245eb2ece9939ae56f63d9b728314902417f67781efd164dd5d6c27f8bfde5cb15187e5b232d5468c39a862105de7c3491250ef047c4a4dce0dfec3710bf8b85dcaf34db811d43f524c84431a60dbbd89653143d93b430663b88c8d0bed9560ba3b587da585c83c32871343189d6d1437c823a0cd4609e83a9b0b6b35b8d76049f919c0c816050bada3cc0ce667c2c97a91ed858b1825685026b1145dae8e2c49e44a2b82979222d8597630460f5794fc2d2e40ec9992b013bc1118501609188c918e2e478e050699ae7580f95ee80901a308dfb9ba527d7ac967afd89ff3efca73eb4466592a0545226dd83f3b7decda2f7817a2b9a9fde15775e9927dddd8b74267fd5037306023cdaf4db2bb85c795b77b789c04d704f4620eb83a352273ce5615023ef561add028c7064b044c2afb548d799fc651724fd3992bf10467610f675a395100f666483c69290296980bf8d5ab322c8f374df56807a41b3249aa73e1e603143f31dccd6d3a67026f94ac57fa6ff355aec32b990c0fa9daed396486ae6d03665c74fe362b4ef16c7d12fc88dc06d5bdf528cacdc13b85657341f07cd9b5b28a2345511c71ce54ad2ce0fb1773de1f4a2930e974a3cb8be2a984da3ec6501c9b62a70ecc5ade61bf10f81cac4b9150ceae5a97573f91b065035885b1c778735caed5161359e3b8b8693066bb903d94b69819ad1e25df4e29e169d47870b3a9864f86ea9b2d4821e98a570c76fa5a7b4b56585ff438c49ab27bf54921958646ab815166bc485a027eb24b3b2179805f377bd3dbb91a8fd763478c484e7a7bc9a58435d4c105d49ab6dacc0d168cff7df08a40a3a613c498c13ae2e46eab2479600ed16eb98299aa1e11f34f64b64b6751e572d567246bf0dd985a8bed154974c91319fd96ed2ad76cb867df4fd576190a2c35cb5d5ddd633f618838c8b64df459bcb24e65aff8ea9360631aad91728e66cb1ddac22a278f110666442433e7528b2a7c272b143a976fe89b0b2dcef987be105e56372d01707aca11f2d8330ed18be2a3129c9b3ac4ec104e23305295f5acd522897d1b103e2297ba62adce2894719a0ac3d2303650aa4220a9b754f6dd617c1525ec3d544da0caa3a0e6d8531de03e0782f963390fc0212071122372f48d58dacd38f0cd1c68c19afe15823cad778d48161cfc9698afab4ab5cdfe7b1bca07df06f6b53793bd71901986917bccaca6e48a0246b3cd78827410f9c49219c405ff5f156e349cdbfc6659b61581b3c3b6caf220d3aa4159706f983efdd0798a2a66a33f65495ec7aede8a8b55e45d3e1ca8346a410b47adcbf332d785c1fab95c2d5ff299103ffdc7b2848e8ff60cc53426dcd5b655af97d6ef320a9fc59c26d38af86e1449f8939fa6cd0a5fcdfbe3dde93ab302af9d6cdb64f12c638363263e7dfe0abcb79618c1addfdc74c72a885d19161440774996c777961ed3b36132f94ef476902d3a15ca03f347114d600a2ab8b266eb73b6f132819490790e83ebce5632f34759bf4a1288b6cf8fa8cf40c671f68f35aed3be66c239c1eeef9f353f665d104db3bfa3bf2d316f929148b740e548588013c69fdd93f5e7b7b81ca99ed797e616516485a603ca02b86accdac750ea436ccf36c6aa94f2ad534652bf969ed1c0921dfefd591d67323e120a185a8ecfcbd08346fbbf3fcf095d10c339cac1bc1806d8bddcccaace594209826463fc23be53f404a4be9fa11abf8aa94699c732cf0329b995d2c55c8495f1d6566be955b4965e71d92cb5f27f83ed6ffdd059feb525920229a008647ed22746d7c7bf8c5dbede3189fa73d6e2a87cf36b26c757c0df82848d395187b075661aa5374b6ee5b9571701a0a11fa8a401247e6183ec05cd4d729a427df17a2d312c0e159933dd9e7be45de0cf13160fae0a615286fa636ed75723c146cc1e6fa6700dae0d4bbc68f91b6912190dd9950a83bcbc5b586c1f29aedc5774fab3da90ea51e8200211e51ee0c1b02d73af7563719c4943687542816714d73e75c37c9373f6e592efdd1c49966183e5d9e10c8e6578e72c9a4d2d698d4ffb7ef38db800c3a69576827bd999b21f78e92bc76530a65966b484e473d7f03eaec2e26f5596132f96e047e64ba51ff4984a9b19c7a79a20c63872801c3f7fb5b40299ccdb07683a5f7de272ec0370b6b061326e96d01310bde053172ab348cfd3dd263210ad28997dabe726fba9bbddd9b16f29ff12cbe3284ae7741bff4b0457e05e2b7c1faee29fdb1774db64dfdc1e5d319f958dd794c24c846e8782cefdcec95d18af4ff3c8c62176ad5a5732eac211880840434b1c72c0b73eb86bb80f5dfb2e83b69d59be9bb8b5650297a6c467388fff0684643f51ef8a4fa5c26ea502633410bb0b1ff77b1cedfcc4fb8c23fa9452d6496f909fbda5d8f3b9aac7556158c8d51057b3342d9d16aa01bf9604cd2a46b78686ef0f7e43fa01b2629ea4c6dc573efecc39ff4cc2a4f0ed66bb57825f9a24641c12e75238fbd5d9f20b41710eb462d7d5679c5b2833ab126dea0d469fde06989cfe1d95ca97278c389e6492e311e3aa314e11075bc010155242675727be840d50a5ed0c80ba00dfb4cf183692ac231199ee321edd9fea38bee0e2551eaf2281981fd3c5db65406b5abfaaa54a271270f76e758bcd6659edde901da2e47ae353d9b5e14507d9e4a47107cc44db0297f878f1a6ece8f3ffae05114820c7151d7edaa28e5cbf502c6abc80a0b6b423085a4179fd3c2b260eb2d3893053bc39421f494bd64df49920955b8dddb37045aae6ace868a6f7e89fd7d42abf5c372072611ab6911a0227dc0728be4370dc3c53acbda604868264fe3fc873eb6a7a0a93733fc088f032c2167183399637090e3717be583348b5f6ce0758e0c535cf7478c9187f34100434957c792c8a49f4e00936e547064ec93d504778700944b64b981ce59482ae126a1c4d34579baea6518e68e716d7f7a27a0dc2f1773892711c10861ae7452da1934116ce0b303a113ca81fe3f2216338d356f160923e602d0549a2adf2edbb75cd5a116f793ef5dc5d93eb3fc754c90b9ffab640ac9e59ee134670dae7b9fa70bfe87d099f08fd6f3c577374a63bdf59ec0b0a89d0f28feb1ae02801a91ff1bd0f863346655b1ef2759ddc3cb055f5d4979ab51322b4677ebb8486fed50decf113b2d81a402b57a90325faae622a5bcc9a8a7344637a5f64b27e89e0131cda743adeb628653722e540b18800d86ecbc2ce496be501969943448fe3b4929d40ffa42f64dd9331820710f39099230a61154eadc11e81de15bd971443ee3da80968ab7a5b75fcad4b4b2bd37caea81a5bf4e5edfed5340cde432888cabc38f4ac2233bccf88b511f9ba9d8c47eac4a23a21c8b93fbec5dbdce52e394d65c3e1037990cedde8ea2c4e86fa393a8a91b0b62975aab0be0208333f58b2d4ebf7f902083944718489d4000000000000000000000000000000000000000000000000050b12161c212b335a8d635c4b1ac6515697b8b90cb55cc7346b45e5a1ed0af9fd3a0fb30080a1544a925265a9da843e2cfd6045beaaa94c31a277824ccefbb754ac21367b07135612d52b28f405284e335bed5ce6d46359dcab59b375c37121a8f0b8cc694d180c54d8860d4c47f15c8c2dbf5156e5ea21e86780fe22a15220568b5e009dd94e49ea7ae0578842b78e4d1b1956599a3202f97dbf08c6cda52814767a8717f3f0e266b2f0c10c078209a3f5a4f391d0773e0513a77c335402b89815f4bea3bacb01ac0443c4e3f77184d3f53aa4e7fe92cbfbd562ffbcc5d7f99cf15eba066ee3cdedaaf07a4031620defc334de0125a534dd1492b1152b69e5a2310a46ea29d881fa3e3f4df406233a5bf8634a40cd7939946a99d0036d7be5e4237c182b4f50a689df9acdb0dbb25b80673604cd717a460c7cee587816ab4e13eea11dc16a6dc97eaf1dcbe84becf2a8c0d4844e56abc0f669f5b00244fadc2e9d1ee709eee290fe4e83ec24192903c0ec4061ebe874a6cf0704057bb060520b155792d8ea399ca37f70d8d8cb56e03e8bf3d57d7ea86630d1d8109017df133738a248332c8a116483e006503e47c72fe6ae53ff090deeed99487336db0dbc1bf7e9eac9f9281d1e0b41a80f1288145dd44cd9180c35cff47893188d28dc69b40782ae277e8e4715113d7157d6453fcf1dcdb1a0f9c89d20c7c328e216b03d4322788db3c784fb6c6964656e746974795f6b6579590c2e8bb511bf43d4ac47128b2945065f1a65f223a6257b187a9a04b1487c28c710d27001567cecb164c51d3cbf78d523bbd1e3e36e6debc86666384572304406e3c4ab899e573741245268b2f8f06537337bc403461709104890b5bdfec8ed89d180b775c6f1969e8bfcc40a410e65117918a5d22d610a7db2ea8c2a84d51be0d6fa93b4de33fc2150b527659be381be57a22c895371b06b9e71e824a03f32392538886c282383a13f32cdbb0f8f6abe04615fe05f3f74218b8b7885c31612d828d94e876b3bc7d3b7904dd0c52c12b8448d16a1fe899e0e40cf11899dbb9a74fdbbb66809dcd7e0d4026c22f6cf6d6e4619999dc478d72c7131ba48a39f589d7f3ca3a5edefee35ce79e4b383b3f5b75ce052f1437dbb20a9bfa17f87d9ea0248f033d0f9579edf3bd45cc2ee973e99f37ee3d95e71d299752046acb6f54081d7165aa530ca27448dfd4d8abadb5361718e010052eda5c5b1c1f7ee470361592b31a6dc146cd015f03e6137186e4c43c8f62a21141b2e8b52eccef00eac32d987ecc2871ed21104abfc674b09ec31cbd540a18758da796bcd5b24578d6ad5254d4d3babd8d304a3787bd6f36d22f180f57b98d97c0ffdb1f4f98646c11b85ea4d0838c45eab132a3b258e07eb3b7e91a3b8824d977444e86524d90584d2eea80fbfac0f27d47fdd3b733490de0f984ce79174f71a8f9fe7fda4a2ca69f37056d926adc491914fe2fe4e7915cb376b3da5b599e65ad1859424c1cd4ec44945e19a7ddcab3150efcae9067856aaaae3e53d23b1a08d2d17b1d718d12eb430483ed26a0602bedad133c79ef7fc2a06486fa011b2d8a965966e422d4d920d123367e1e5b6b8b8ff5808ecf0dde7b0f9ab15aefc451366c62b9ec0cc13ff778977486cff767d929788436e0ae4f483e5ae1599e497030ccfbe92c989923b8c83925b7cfcc05d3655c9e0cb92aa131683a3ea77151610bdde407c7e6f10a4210b2a7e78ab2ada5f6dff573169d1b344c835581fabef8fcbe58a71a7ee787e52871049e4580148e7fb5261bc543198a1bcf8d6a041dd9379f64748cfd0d7af9e81a4cd67b9ebd59de917588e1fdfa49be9f9dcfae6ad7fb67d87a5fc3369a49b4cac51087f32d702f11f0c18d530cc39148f843a5186d5b707350391870e6ce3c7047d46a51ccdfce7f49bba048dbaf57f160d88bfa0426e2095e8cc96026d41052d5ad03ad6d4b3cac6b01024fe553f1f55f54633900c2daf947e3d289d6282f6d60410eb10af338e13f3e16de9047763bb24ef4a3178dd9ddcc86c34b63a601ea766eb370029d61b6a5c4727891386a0824057de68c243f9a3b36a714a4b86c5e1298ef792d62031e5a9d60e2c01b7f68f0b1992884894be7945b51853007dfdc12597ba2ecc2173f755a054873d77be7719281d420706963551a941cbf18981cb7ed3012f53d7a17d6072edd86d6a3c254a53a0920696bb5e0aa330ab2ba838db9d1dcf355320bfce55cd2f6b192c36fa119a358ab1604522802ceba3caf001c262e4c77048224aa1edff0c9018cc925b7882fc7336e9341c03ed7cbe6174eeb1d87b6e93d71f15be6c4f5967069816b43c222562377f606614b4916141925242b19530d0be3708b49715f29e2049087ca5535652da077a6f86b2494cfc52d74b14873476fa70a6ad6b42d1407ab1c4dbc8f13386755e539875080d8c8ee656c2f2c54e0c94ce47a0517fdb00fab37e4287510ac683c125ea8f529c6ca6454f9f05742c2b2c541407a195b49883faa8e9d26d805c3b1fd63079a60bd125c55448733236528ff5eab536a2f5098bf7f70b5ff697fab762f9f924ffbf41701edebe0e034d375f809b425f4baddc52cdbad9807cb488d8d7d3f54f952952ac0424969b878ffcc37217ae1bed97279a13b711d50e73618a6fd3f91beaed8ba8e624db50b9a333ca51dcf6dedf87d3a9b33cc7b0c903b3f07e248d8144d21278da52aae2060efb975aa9d9c055a3e7827d19bd5a980afa8d5de49ad223afe167b0c7e28cbe86b16b6cd0f9eef2f0aa876d726bc666c8270ab651abd0547c6234769119bea11af68870afd998d6d409978f1619a912728410c88ef223a63ae61fd0c4ef8af43b12ec88b0cf2deb7c9139bd9596eeb88d0c028617400ff494c9882e0b97624f2881a1a845989842495d3e72ae800eb63b877372674a02ea64ed4832bbb31443b6854225c1208581a8c55a85bd7784786c4dd88b40aa29c30515a577f91e097cf3c43d746ea82e5bf3280786ac1447824cb9665dc983525ae17aa4e4a173d6397fc1a141aaeac5ce11cb708bdde52689cc3eba61f325d08c265ab3598dd3cf2fa9780243f8e2e7b896420f30d187d1abab776a49dfedc1b39fd468d9f6ba2d9dc20fccd7537a7555996f785581d7cab7547717b8c4609bbb272122fa720b4faecf215532f2fab70bd8a228877af52626bcfcedbd235b75a1452966c31a36a3250fb10859a1bece4ce6bf34bcdcb342058653cf4ed1696d4dd9a449d20ab0d46c454dcd474f110f240b7a9010369b2d011adeea631a7f379c52d11236455fa557a4e79812f1027fdc20f4a44e851e3f8bdfb6c935df392b1c487e9988acec50adc492f08d07c40bf0a108881b376919f3dee364da5aa467ce223a20aff39205b47df1fcf84496e437f4623ab5b2f0348ea50f99683ade32dbb30af168544d004d7171315002b3216669edcc5403ed894406878b12af55aca72378c1dee94e19d97dd6036704d9ec158f730037662b4a27f1cf21a04658da3f46c92e452f65ad37f9b855d3fe5b5dd15d5bade4f2ff2ce656b9d705650f6808c304dea836156ff5e2ca299f2a17976055348c2eaea94d290ea18d0678e78e0f13518a12296eeef5abc5a9123b27c7403eba0239481307d82d28d5650a0948e0ff993a157c66258e7688d2ae3b545ae594c27d4d27ff6b1cdd1f2bc9ce3773a39f719ed5eeaaa6ab5eba9c9fee068dc275348fdbd473596816f38342d691e2f5dfbcd4f5d862c297ec4d5e1c6b67357c3409820b43102b2dd07c072ab90ce64700901c7ee8cccd99e7ed0939133271b8cc6e47385ccbfdc3c7562f4c650adadae9560e6a26739999c058c0cb2875d54de26e0e54914a64f0e8bc76d954ef4b84fab01c98ae039b450ce08045477e5ee7cfbf614f32b9e637d4506034b38d5848d5a566e99ac795e2fd14146dc4023952bb9d8e669ff1604d07c5f071e8fd8a51f4e377060618af6d398063f835f1e4b9d25352ac79c2e3efb1b92455a51bbb79ae626c94d90e908a78a7e31c4c8a4ab2121cdcc417faefada9bc9e101b9fc3c75a28e1b4acdbc121341c5608b8bb1ac15d883c3d69c66b78f039a8aa94cbe896210238987e6b8cf20a462d77d8e01d8ea07df23c9befbeebfbd99019704eb2b4672011d171b0d81f6171f2d9acbf9d98dfbbb11d1aa48c73f850d780b57404c7b1f054341af995772a1b3eb166ecd9a695ba8d12bdfd0965cb515bdced0d220260a4c70a316750eedf9be993180106488a4fac28ceeb9b8a9d57be7663d75d240c86cf7617488b73c0185da43d50b22b8069be4d34bf42d0a52742ff417a673b96b8e9567cda4d2328610ae3fe6cf423256250ceaa929df7ee8297e9d34b9e1d83b2cbe88da159878f3082020a02820201008dbdad89bfb4d00882ff84492fa70d1d8fa6e24eb252427b5364bd063304d7321c31036d2db9d9411e4a098d977b96a762c9da4c0c3d57f1bd328c1dcf7135f8b3736fb9f20c02d7556269a777d8b3827aea0cd89d938c18a0185f9a6e1726b87972d1b0c87b45b8c15896dca7ed99513ec6759cabc3e83a0b78ae3510723763c704802098da708cc5835612932f7aa354d88eef83bc120c28975a4309842d05ddb80998504d0ab4ecc970fd32ccadcaab5cb1ded2ac23d6a63436b1c1896f85de6fbf49d303557bb26dd4c861cada234a1bac5262e754d7bc26a530cba38438a6c8c39af2d075fb456b649113ebe3b8a6165d82afb561781c225bbb9f5392f964d150708e69f0fa433c7612462b8a449be69252bb2834777021eb07c822711eedc9194b1446ed9cf21789b11296f9ca74da79fd81e1b8aa94f031bec2e3a475f6ab1affd33e6607b8abbc81b294322981e8582c42b8cae0a3350aa1c073f56e8f660390c408f0bcffdb2acef85024fd82786e40d757af6de57d7322eda480cf2180e11bf1dd29fd262728c6f243e87c135198f15c3508f977a308386cdd9cc8d8630e39144c8875798095d381fe6fd0ef8c934ff204f0c57e456ec24ff9eec15ba9c1d2c32316533bfe2df63f67d948174afdb7d1cf24e2343d7bd845bb0196ae51f0b7044902f3bcf7eff8971c9ccbc86660d028ef9364db9ab4afade886470203010001", + "erlang_connect": "a96570726f6f66591413920f201b28351aad1268c6c9cce650c7ce9ab09e53a840149760632792559ebfb688b5abe1c9c3992dd369bfb66d244cbec51c72e840980c2674810577c259b4fbe0ef6a2a50b0fca2e514c74d729382d1c25dcccacb75aa5b08fc9255462f6421315354222cb41be65d78d5089bafd15c8141d4c17bddc4d53826e67d5f643244241156165dac57fd2f5aa33bbc7f6c85694bf8e49e6944c103220927632f717dc0f8aeb670c94e9a97275844e833d4264f52f019aa85fb14113b6470343d0e9b0827c555974ee40131a3aa7f46f5bbfb9cc40ae20a08752a89265bd5378f00831534bb458db8cdea17714af3f5b97c37f77e379b823219edc59aab20666bc0e5c022dbf0ba9e207d7839dd889356d866d9117a6db9c83aea6251eed988dc896b5ce62a51edbb95187eab8c32dcb1e74121f68df8cc767fa26402defe215e70e8ff7dc2dce1b2f669c407d336bc987bb3ce4110482db298a6452a927ad4a8bed1694b4b31ad5bae65f9653a183923b1b6284114402afcbe5416a72687321c7e222bbd412fc7fdd4dce609ca33bd0d78d457ef669049476ead092d2dd82d8f4ef1f2de920ffc19e36cb0ae2bf0ee1dab0650c069ce6b3974a0390b4a8dbe740b6c7f33d0b3bb342fe5d4dbff4487912cf623ec1fdd53317654b42dca32ca0b87096a3c00a23a48a1ef9c0ffeead5aa4cdd3ba42bd43409f448fb64078eddd15026514e103c5c8adcba3a1b6182d43c30ac411d94d66be6e7a8de50121e43692e109543778bde7de99a34f3af570f8c604a5894a47f36810814550336b7d1b42720aad5db7efaa46c1c425365ca48b2df27bfadc518333f3d1b0a8ae13df63028450315afefbcffb5431e22bbeeaf0a7df34768083978cc666f7c4c68d7c74edd90da53eadfe1a73e65ee8454d45fee03bed10595c3aa396b81486b356cf03c04d1d1482987638348cc2d2a9c347926b16a9f404f63a47c8e4f14cac50c93fb97ac385d1435667884bd78c70159d01f4a4dda1bf5050c5e60dda15e9caf057ea6132bc3369c1304cbe00580e3fbd40755aa1acd5fb4f17baeef6f062c36ece3c1e372554ecb7c8dcc5b5f815c53b4b64d150a6d0fc744efb2be574e28e3bc155a3f0b22b2e05bb9a878ab618195014636f1a572598f03995ca7a053c6360f2913102ed93bbf6e9548eb84235df9ee3929894d9baf721be1d6918d880fc436c0ba51c8a385e23f12ebe179ae7455b3f0926919f16b02ed6fbf8ba7a57304f2a789bb8aa3038170788fb7b1aadc5973c882a570c790a97cea448f3b539ccaf1a4b5a541c36e7b8bf26864fb812423f55092f51986d38834a232e2f9175f46b08dad1ddd1387662ca6a40413f5005a5df77b5bbf6cbcae50f64e2d467cd9b9e081180d346aba82bfb6575467f9777950529fddeb878b608b1beb78793f50829ea29a3504c2491ab8c4747289608800ec3ae3ab867953213f8782202ec25dca768a2f12ee8de7bbc0f5fd327278cb942bf98c01f4ec79ee606a9d8d1223b105497d62a418a0ae686cfe9604b4c7200f5f1064470bba52498996eb53b0ea6a749cf2adf307c663872be233c34d1baaa14ee93edefbd3c1e57a441bd726c7fb0ca1675a798b8ff790415f654edbb311d27fc555df7b89e0d563678bafd33666e07ca79a736b409378c3ca7d3acdad24098e33cd2a6f6f53533edcd271af93e2a2bf48461e2f33d18c522d0e3d4f21be913896c3c5b9b801fd549c2429a30fd86df46c9d501ae249bdb8399f6380f1fd66a1868f1a4684a5a5a143a66ecace1849250c4f35c9622fcd845c9da1cc582a6b62f5d982f4638a66fb1d47cd8e516d605aa7328c7349d4a7a0a24a4a1c6f211743681d6a2891600b7ac4d73ec99b244625c4b5a404d7877316ff38c68298b40c7820eeb2706eb4eed09566dd8c43f2d5163d7bdd9d12cec846fbef470f4ea8d5a7ccaabe11f840799f3bb9b646eff254e99cc81537668f321904664f22117b3b5f94da324e123a9cee58f6633946ee59bcc2b6cb4dcbc4cc9aa51f56b56ea980b8248397110e03454caf81c41701abf7202ee7ebb161aff331b21753d7bc925f05d42b4d19f44e1d125a2d724ab51688f06e4cd1cb419c55baa067030eabf81b8f0bf0f6d0958e7db4e7208bdddae6d45983c0b333496afef3c5c8ccfff1fa7a237e838a76e031791b438cd69f564e90757b750dd50f5f72a4375792f8a05f44861a2930cb9203cb9d45cba30d58b6a2bccea5c88d1c450d38b3ac850861ddc8085c153cc708e8117046e40717ee594d8df773dbe9581c152b2c1b5e1750e876c6056a546c923ce10a54237b4e80c7774623135394031d1d30c9941ac70a9681132bc7baf48c06645870b171f9fe8cbe8326ecb6315c33d3b7c11c4b164b81c88e17bc9fe0cd95ed77be050ce64ec648e5cdfb7f8c6c845a57224b7f4818a5019a84ea4667e1baafb37976dea3648448f9897e0dc37d93fe96cc0b20a26b948c7c7582581e55b22862acc6b0f6160bb52ea2421c29769918d3618b6548d92a3bd2e51e034ba951199484540f71c1b27699343d4b980a5579b9bc4f1d26bdf345688b1b4de9385d5b3d1065834c62d9f530c7a8c990fd0d9939811aa61840ae3afc4948dd173f3fea643a3449a759e252430dffbaed9853f9ac2d2f71f7627fcf6ee14a81bd53aa9003423d1dad5c01bd047518d75dadc5098699a363b89954abe17c394a2c3b460f0cea1c88e587906aa2a46044ec62156678c0af843ac759f06662e102331c381e8f87539775bc7fa181a6ecaa2117eb91f12e81cd4ed636b4201b8fe8561a89bbb88ad0a6c17087d1ebd96bfcd1db665c4ecc2437f79c62dbb9d81853cf9b547b61c9832af2adaaf65136dc9b5c4bbb62fdf40f6e3803e470cb7ea44f5dfba5f5cfafc95a3025f2be40d7c20f0f3c3ae10dcb5680e90544543a9b61b9a803d01d6d278d139234599396b5649968eb7cd439aebd32bf4dfee3f92f84ef612e64898c56d8a4ccfc3be461447ed228d472ec5f47a7ea311319b1007ae296ba09605cdcd803336d14f076c9cd1b9652cb72337ba53733e783a80af4c42b104a6820135bf9f4d47ed72f237793d31423df2f07855eee66c2c43f6fb0c60ef89e110662ee5b34260a910027daabe9f5ed94a2200f737c89b50b200164894420152526971199b36492a0c64b4c3a538aef343c9ee701be42f229d1efef4db6f2c150e047e57e7c11669eb6210dcdcc077b1bb9921d49a86f4633e5af6bb54f785dbd96ec674ab2fbb78162bf2ef0772a70e3926801a52a74a1552608eba7556580ae725f098e6447031107a98fe59cd998bddde50261fdc8b2a4e3474597844898670851ac09d37ae5cd5a64e7b5ec756a3c2100e88caf9aa7e59e580eae3b8b22accca14193e82aacc573798fdd9b4bd28a1b715b2e82d4b2b5b90defc55c0aa65b0bb78ca1f79e9934ecf0fa8441135d024a52e6e53cedd3aab7aee23c93f150ce9947cb9519c487f58100875492ada18a5d45d9f624510539ce404fed388c07acf50f305fa44d756538d6113143d7036080c40b564c761dc645bf2148dc602482ddd700a1c4d912cec55d1b9822f27d812e10ec44d14c65dc0521ff518f21ea438e0f40bd87db826f152575580e239433462cb8eb707620682adfd126d5ec78a4a5ec8df124a76db14774cf29175a858af0938d6291ff3334eca1fa3bcb19e85e20dae196e856fabaf046b82a6156ed2ab93ec658c37a49ed2d9cbadfccb39e530f02885c2fc1c97d7b1fd9f89d45ada5c1a8088b0035a709a51d5f3b2e5fd882e466bffceb243b54797681e62302c18cb911121ea5ab3269d8e03109dfb013d165546cfb3d6c03326c9ac18a490a6750874b8c06d995871db6284de2cf1378247d128f08a623782b3829c9cd44f425acf6dcfee270261435ae861cd8ae016fc84888f8788b6478c0ccd74e4e283eb84ae287dd2feb0b6f4cf6a941c34417a413cfffece044eeeb04140de7e61246b7c3089145bb8723f278716a3e7b36508e32f83e9c17eb8a0eed3499d46bead39596aa5ebd756eaa9990e043911ed4e05ad570e12116b07c8e6a31f938bbb100b271cb65565e8d977e2263d138b69988a05840e178be4f0b35c586c7a286d9691a1267e7fcde2040edd299eaed4f499f4b8771af9e697d2cc5c60a8c75f55060381724022f186e5d2abc1f5da02150ce1eb45319095f59c23fea6d3a8f724eddfac4bf52693d966e295ea6623d107fae4dc6ea40b36a04256e26b324897bec2fccbf9e75d5d49232efac84fc4a8aca539e930cdcb6e9fd6a6aae4f224328d0e639f7731b83b015ba4b05b75f9b391b0ebd6264a4a5d3f22139bb77f883a014859d65259e4b1b9f3db98c8dc47f43d23205a06a72872a4ac488efc59a87b3ce014ed2b9c4d4556cf257d64487fc331a5bda42cde0988a4e9c77ad8ce920c7d05462cdeae2beb326b800878368187968e0b67a2a4c50a57b971fa71fe8db1cd5ee73b840155f47345654e60641c54ad40916066580960cc009e191d4df8ef4859e64c044b6f8b29ca91e6c7f9784f9e344a1d90ae45524a76182c43e097133385aca06f13f3be3fa112e880ca3f4c5f8d782342ee527e5a55c1f92c6c7696f736fa2010e58bc19e2ab38674bbeda9293cfde629716717df71bb375144e95d9c2ec5770ef1f0224c68e8c54ee4ca6e41e3f75f1689808c5ee2e5ed16aa965308ad8538cf4f229761b63c60f6f0a45d1b9269cf1eda5f9e2d59dcc4d9029193bed07cf4c7fea18f965345b8dc8293986d4cb4a1021fe3b86c57ee760b3536f642ec9535e7d9327e7559d92ce13c404c6c8cff02b7023cad31e76f012a60ba0cf9691d4c742373f8a00fa80bc9df82c09776344437f6edf74640a815482022efdbb039878b136aa9e88da1b7214e8fc407309bbc86cfff948af02ce9fcb40a4addb6fc5c78001fdcf98e300792b37de27c89d3b869a2fe18b9e4ce58138ce66747333531b8e6a8d61634ef2fac30567f4e0ace587466ab38075b24970385c8a2f96de6612e133c2b94f690a622ac28a261068e379e3896e943c11e0bb303808cda9da88edec73a85b5247b9874b3829cf6c3a6b3fedfa62cf1d5cb5323612f23d53dd1391f3c47fcbf7b75962527386faf77a576072d142621d5975e625ab381563f6b15868080a94ef2be006f124a65136d5f6035d34121b94eb619365f2025452e4706746d268ad2a869d288f31c08c51cf00c4f4cfaf686985be8400ea44ce5aa1562d55fa9024f8a7c90ff7f728c17e41722108cfb86a0f14e51b0c1544181d101b077caa2162117c4172a34042ef08dc683ebe408c5490f2982bb9f5c60f84c0ff70082067f715a75821b5a5e23f9536252fd2d93f343a3a868a45a2af3a6ad98992d3891e14fa875380a15f7696fb633c55d891fcabb5644c9d3458752d63f4875c3ce9756faa79f24411b08141a7f568b0b7eaf740af2a56519876a34ecc80393d2cde8575e831f068e139fd1c42ee0c564878a06eef6ceefb51c90882286229bf15b99b133c76a52c732fb58401a76a82859281579860e91205f15db1c7f0a48d941a21c05b9d2be9c2ef68888386e4369a85469fab7b470370e3e28aaa29aa45dd625303a4f988d2d65d768179e818db2299935e4a54437c0fa2aa7bad394e6162726c7226c0146c0f04db5ff4eb02af590fbc7442dd712cae0a144f67c7cb9b8ce587324db2b38e4b029d32d8275bbd37eb4ad56057b9d453b2f498bb735507a7f213b0339e1340f02287afc16e97ecb6d56457c06011efc9d77343e1258798c4845813f088124d8c1afef205736575258a5ef47735ed97e4236d1dcf635cb5d0ca20d8a34fe6c7f3428040e85405a5f994e2da59280b9b43bc9cdfdcece8d8ef2c09aad03f789de07aeb81e1e5e0fb4e9168498e3df04f45e58571b44c2790fdaa6cadb9b4cff5ce4dda449f582d321fef2176af4463c621cafdae0db7217677350914b309eb104358b7161d4e99c1c1b792c9d7a5dfabc040c4d305b3021854701a7013b769ed41990a5e62aab921bf53afbbc0bbd8cf2d0763876d34e0f5337c2eb5f2eef46be1e033779fd78f20b94b302347631940578f436058d7d29a140c89d95335b90cb0ef249dca5e7fac9301f389c5546595a8ebc3c16219f6fb0451a7b3357caf758b33bec03bf64f5b30bc37b01d083340ebe31080a1d383002db187d1998d83db585c295435b7a922a3dbb10a72e8bf97fdb810179a9c407947ab4cd40154e9b07c491fdd25566db0001afbc611ef434d01518433729af292d147baf560a2d7e16893a25fc4cfeb72b85621c9a47a5850778cf9aed97d9e0856568b5d178666819d6720d62b568cb20ef3ca187a1a4ff4604ca0afb5081664797ab2bd03151a4d667e869cee1e2153b0cbcddae958979be2f710192e3951a50241c2c5deedf13d6b7e8cfc011b405776a4e4e9f0f20000000000000000000000000000000000000710181d232a2f396eed439ac404ef903583de5d27cb4e8918467d21cacdf4c45098e3b073ba486504c4346807b52c21f2ac1edf0fe5a875bf9baee23105b64e864cb3d6dd7a15b3841d96aa5d416c2d60625533ceb64a207ec4434a9993b0e282a7aab3c30f48099bfbad73c53758d91c09b238baca0881c5498cb36573a30ab6e41690ca5310b0e7a5db31e8166f2083ff4c296ca7a26a51d51dd3273fa0332e451e898a77d559c70755fba6377a59919885db14b2d95e54a25c64b46a01a555c038e3417511404fc9157456690882ca9a014cdd47bfd68f349f67d18ca4f3072258c6ec0f9f50556a445558ef3d4b13cf2c7f3955d5562ecd15501c132a174179018daadf89406b667bc0d6c4abc9f353197ffabefa007faedcc4a530fbf08bd3c0e37d73df1d2dbedaf91495fff3525abf6626480a0a1de548aca44861e885bc130a3093dd3d234b3ecd991e95952d23339adbc2b56028ea38db88461449486a65a8eb77eb8d471620c0ebdbc5e95dc6c374a338ad6a579c37e1f1eb320de866aa123df141f19c29c9f76b5cb3fd1e88e653bcc356b1f9250d54602300631c40620d4ac127591e8a1bf41f37d975e1ef765443eb0012feb60fbabcfac4f09360d0e1513c2bec5a8fb2f888517def9ed8708203e600a575eec78d62b42b834fbd44b654be0d5dd821e4c76a3e9277d8ddf4523b769bf1ccad00bf79ca87364ab3510c1e0a59686776657273696f6e046a6672616d655f7479706567636f6e6e6563746b636f6e6e6563745f6b6579590c2e342f3c94fac1c508009c50665fe3916103a28b7a4ef2ea518e2e6c5f25a8f065001a4ad4869024048934f3b65148ac7891c584fb34364a085f55b200c7f15906a5d2646719b8d64892a8d7ff68ca1000918a22d05e7fac18dd94fed80b458499e5b2dba450171d414f2029fbca0524bcc87a2cb8cbb989eb61b4ce33a55015fd134d472559dff8d425004bf140d88e0a4679a815c3f7ce87f40ec03d8e286bc580e3dc08a0eabf0c3b8e1618f347bb9dcd2a4bebb8652c88b73b16405c1fd1f2649d605d1ef51300c66ebe9eedebd52507d954dd16baee09ffec44eca2fa984967ac642dec154cf0b60e8191974032dd2e23c37db36a787ed137db7081eaf6f1b3fa3bbad926ee8cc0871b57b7b4ec0cbfde6ac8d2c86e838836613e554cc79dd69a1e91fce49658967e54c5d6e59886d5bc808d9004672043b2c345cf015b8534f31d2537de1694cc991fe0048fbb0b971fe6de6d196ef51bba991365a0dec472007aba0996f599bfc974c405050a4b80a130182445c0c5b35f367ae4ee8d59fcd5bc17ad8ecf44bf0cbaf6da18e706eadf99bd24e5c38f8a6b0d88db942b90ce8607ee2d49ff521ca7db7e9ef5ae9b9dca1bed5dec8b171498dc62e6a1b177012edde5f13bad0f0d727a8d0d839eabb1755938d8222f5e06d5500bd7bf857c8df62c0b866cd2b1e990a4199c989c44c265cece52b20e16f22d037eb74a7535c0730f28135f858c3863d84d224876b146308a277b53e7553a695b575d4f489482f82eda8d0812f35b0d55cdd873b3e2d14066e09f38eeadbed2a2c490290ec633dd36a663013ca0a2fae3690079ef770fdadd3b6a6c3e6532d8d862f19bc569bdc3d2f92b28f0b7185e1345b3717046ff29ff53a97f19d1b45a602336f952b9fb9f8eaec5ad0b83fa292e0d3cb0fd4e718fd1be210b1ab47628c6de14e8eb01cbe9b11cdcdb902e159f848085568f25de893baf244e9f4432d2932a84220f491506b6266539f32b85ab347455e8902c64ba19b07903b863a5f6d6d550606c2a568dce25239db0ff05b6f6a1dc40c17f4c3e2558f38351e8331ad22d745046fad3f14209664c062d6fe9cfcbd86fad18d33508a52cdbb5ce5a3e6a3625c2d5e70b38137bf4eae5b131889fe0100e60d978a6957076477fcb5853908d9c6fed154d145a5e3e221bcf4cd781788d772ca53f130026b82ee8730acb2e80bdebcb14afb3cfaaf3072a72d44e6a564c2ae18e3eef2f9cb28a25b53d330bdcdf0bc6db7d30bb6d8cf84b900e252a97f3e8dffd475ecb062ceafd0dc7854b9a71e2387a118f285c8c465431489fd9d6534819312a35cf5b52ebea08347bb549dd3bcd26b0b4040739d5ecb18992b823f0825477821b8848cb79d2b54a757f5e5f6b082f9c7bf4ce569cabe1af2fe466b21de2e0e22ca67857476e64da0d6744600112923d96a816078152a93973c471f1deef4c9f9dfaedf7a2d1b6ea4a09eff4887d48e9abd1b45b12f13ccd20a4a3393abf5a97256b68f9efc5b85248ff6c6afedfcfeded05a6bfcff7ad1d57d7a030a20ad008b31d61f01314c707a80011fc82a5abc93cd014effbb9147766a9daabae21bc7e6b8bac9cd8cbcc145e16cf1855bbf4f2cb5448f220b55e42d6322e8b46dc4c94395a1b8839e642fbd808d95c471354a55c5d55599abd4762588210f7c6d42ff6e0a57d323c3517e426465f9e7bb25ff442d68a1e61a47156e89da5310631d6e4467bcbdb085dbd9c3a1c82d179b963ff245bdda4ce09f78de982d0c0dbe7cdb62e47204fbb50aa2da7f93e2592fcdb38d260269ea9e63891a270fa66d49b16892ee6b997bc9bbe3dbc0c5d09fc7a1c7410ab0e71ea55b7af6582c6701666e6732e7aec2972e080054e92cecaecfb9d43c6aa64dd79e60b8c16f134b8f665d82e3ecf5276f645fc9ea50cf14607d236e2fe0f840f85592af169a9cb22d2a9b6674e897835451fd2d890f9b7db07b747608727b962d45f00facbb0951f6f5c92c0973c8bef884040e2b7f075b24fd671fea79b2cd69f6b634d476a2a033a139d648f5b6e50e25f69c7980d53324a47e6b5e85e1665bdb50c150acd19a497633f34903b5d9c5d1d6982935c862c166379fa29292b06c82a07375cf6a36f3484cdef18f27328f8af8c3856061df244a267751277ddff5f8c841093d47e132f04a92dd2c9b04f99b1c3ba20f9855b172f4ca3efabcd8325cec59866ad01eedf0b8a978e4edc772dbe019e7c113eb52c4d65523311ec6b2e739766562a2f5bf6ea6510a4b6202f8230575b834239e29915fcc35af2e0d92f74e6fab6728ce4c5ca8be35f17eb6515b3e759730625df31d04b835d431c2592d0932029a0c61514bb1dce11273c0793298a2d9a53f760d8712aa1cc103babdd79f0634f684432d6f32876dbf0c1f3e6440042777c070b49ad353504814837a77028c8b3af05291553a92490e70d3498618a0827fae2a77f137b31b511b6b8678b9c3d06235c0c83522bc62774047a5c32a6c66de9106d827d16d6c9472704587537d9e51dc0ec548fa03cd863cc03d586ff3235034442b50fed24e27bd029e74551236b1266ff521de73f9d8c5bef45b90bec6343da567ae6f0a9c9a2fb2e92ab4cf3807dbc6254bf2fc57c4cbaca88aeee8f1d4756e21769428fb4f1e576d8b81d774bc2a43838bc6c1e0c7bfa746afad36032067f4c19de86f51cfcf3ce642dcf0efed305bad79a216b94b0f0e79f980f6abbc5f03f75775d5a92e3ffdcb4d01edb9d2088c27120fd3cfaac8669c6c112934bbb6a72f4a89aedaa5ed126d1b997f5f51874c75954f340482c3cb5d6583226614f464d8158a1ca208e95eb2f8626cf17f7e0f8a457a7f9434c51541a0c1f19fc3915a3a2ae9d880bd41901d0e9e5d515d99e8967d480a5fbc117991242cf3a96b04cf06040ee06ab0a40294af1aa00ec8e14976a0746b466cb66b5b80ae755f5ca5b607dd9ccd84c6c63ffef7022a4d005122cb3499306b3a52dd3843bf03a8658ef08f398ad1cf5e8874a8faacac6600c8905aa7586683dd654eae4a7baef78057067e21efc869d8d181f9d6afe578f92c3282e5bf76dc4754346201cbdb27539122ff0a09d82c7d4c5f8873c55a4519ebae724cad082581425db2dd7eaeeadd0373d8fe544f2299cf5b1c16183f2b2b46c8f1c602c346f02d98eb2d2c40e99af390b30150b0103ec08e6cd75686505affc62585fe24655eed9da762fbc7e40b7993a8deee4bff060f57c61187a9381de40b9dcf31506b54229ead7499cac2a3192989b14a140e2f87e307124eb3c6f7d0a2d49cbb97ede9ec3784b9b392c3c8859278ae2e36f1bf14d5f28a5a5316a5563ca0171ca21b9a3e0b49c347ae064d3ba76b861cdabc9ed2390729484b2328cd38ebdaf9fa616163b00d12596fb7cc40929c2f04af07e7655e78a44b833e8f1b262249f191e76f38776c4385cbaf0917d5c68f716bbf1d147fb400156ca5742ac9c62978d6a7374cc72d23a94f44e2eed3825102993930f7723ea9a0cc4aab99c7aae1c6084ced5aa87577fd448175d0fcd001d9cfac2c8c5385385814c6b3377a35fe4679db976da372e15e293fff528a88f395b017832088d5ab31f75a772ae75e1dc7f9cf97b97d01cbd0805603a14e9d2b9173082020a0282020100a06386dd30087077c960933577f92723b50149e88b495efb3065c39fcfb13b25f4a0e0b8e140065d6e26a248e557c19ac136a37cf2ccf93b5d4669a79ba12c29595c6b6779e9ffc1dd6acd82510351d281753c164f3060e043036b3bcae7243a375e2e125707ed95216c1b722139470ac3c63d5767b1a4376b1e2b099dc4b274b207eeb4effe0bac120005440cc43700118b6d73e0522724fa8599e174f9f7fe2c3dd0c0ef2fa142d52e5be3f294ddb8296c0d16fe202a5ddcc625681d376bcbabdaaccfefa197e41f71110dab684679ece5b4eaa17fbe50fb6e65887a0265fb34ae888fe2eb883ba17058450e6634a05f93a3870b2e7ed8a18a20ed4ea91fbcd526e024cd0ba75180aada8573aa19ad5f11e18efd6485865ea91238407df544fa3f0d93e750ecb4e9c13a81311189c80787b69ad7cff7e8e42697b4131c8bc8d2cb727cfe9636bcc6932990b6f2ef854260a3683a36cd3514735cc869b6a409b430ddcf78663be2a00a04c337226899b45bef1b5ac2a37b84b57f26b075828a4e482f64debe612f586483bf2aed62ae4e612119e4b0d0cef82229aeecf4e625d073ca0f2774a7c514f6181592d03872292b4163308cb8dd3597cc3ebcd728023bc07a99badbe6b475428c67ed1dfdcf54cd228d1198e73e8998ae8f3d8a3589fda93ac3630a71c6712eab09fd79b0da0817b2fe63dd0a16fc555b047d1b461102030100016c6361706162696c6974696573056c6964656e746974795f6b6579590c2e3f006260707566be9c3854879fff53432adc304884c31763b2b0dbe2ee37dc873d3eca6d7e9cceab9b3ef3dc05ae70dfcb69f427e1d4fa3bcb3fe6f3a6bd6dcbf3e15666ee0fdd2437f7616f1a825df8890eaab2eef4d9ce1bae957697e02d3cf00e7990598b78267d644ffea7054a7376b6b9c15c69313e606df0e2dbb88d9dc1b379414f8d8a8ecc0e728ca7b3f55cd347d0dee1605fa6e78e2effb292da77b321345bb34ce2d4b26f1196da7c5d449281945ed838f924ecc9258b0a0e943d618fe2a3b0fe5e46b5a388565504f802c11f4c8b316d402fa89735da26fc52258ebc0dc38c9586b606d5ee4fbf78c1fab4f159fe985546429245fed32f90e72da7ec8afd1100c34788bf39088d8c1f88bfbdcbc5ee695bbe3298219bda6da7ea970750a2cd6ab69670c43a966969075013c80e952a41a0ab8903c69ec3e105ba8cda7da56c903d6d3c4d88a8ca87fca83884529867669344476dd2425be197bbb3529f3eabb17e5042870fedfbe5dced2465e043545df27cf7e14db6d6cc355aec9d5ee48c0b32214e513c1a34a1b03d996138f76ddce61d6d9a215884160eea8dbe994a4f7a5c62ab2373ebc4619251bab6544840308c728308a00c8bbf16a07afd77e415bb2c90f8ac283312c4fbf74cc02c820fd4e04ab9ad2e1e7c05ffa2b694c37a32a147454664f08ba6fa664ef3448004a6b06ce59ae26721e6dcd25ecf3e7515e2ab7a05cab0241a584f37738c99ffed1a5f475bb040acb30c8674a4edae88dcc841105574a4101038381223d77ff11814523d085a14beb56af185ef1a8c5f7bd9715b582cb370d16fcf376862c331df8a0ba660dce5b0a71a6282c8d3ed97806e52cbb851d32620c37a92dd076c5f43e5b81744c2d08b9d6224205b8ba7e98ebea00ff9724703e6e5c0c746488e64b78768befd9e563658c0b327b956d9be2302f5058bcc1c971179da673dd5789574faae6754c95a5cc86ef219f2eaec60c6dd2c6da4bb6f4ac25406a569276801feea4cd76be99b6681753ac5d1834151b3ee77fd91cfd2227aa46c3b60e8339381ead82d4f4ecc3483aa53145bff5c0ac9e08de875b26ca28e91b69324a10863476a61e291ae0a51aa6f7fab95459a855a06a04ccdd2b7a6eb38163b0ad0bcc8932432eb7701da2a9c354089e46c73e80dcf2a85f09266e0f90c16b56e2de961906f63fe11110fe9ee6c097df30a9d40f6e7b56d1fbf20235f78d3a24e9f17c291182931b71785f0ad6165bd9e27f0030170519eacb78bf40838642e4d389eeae900c67d49f388f4faa1a72d581934b483765ee9e5d358b1c2cc3a35f34af3a29b24c884b04eafff703c38f6019d1f2ff541dbbd761fa768f4c3138dc0a5760a35426b92cf19e891fcd74d7ac1be907a54f1d1f805bff90a05beb2db90a9589e50524924b298ad59e2273b3ab70bff2a2064d49baf9eb8f8ce8cf47ebd8baf5ecb295fdebbdc29e403659be2e6bfc38f6fc580ce92c15d79e35d78ffc487ed26e4caf61fe7ccb79a73a2daecc9b411ceeec8de967271442f78700bd249d7c85b28107e668def2a27e57d8950b5f671afd4515b0fc101be080547f956b43069f12c3dff271c770f3f6f4579af246c5a0371f75c3ccec2de410c7a3b111356898044273db8fcbd25ff27f3b30a74662fab68f9664063fdc2eb8fc588d33628939b42a23c21db78144534152d2ce9c3995754447110157429c7e738f9dec8135f60aa302624ba6bc255f4af640c6e36b9bd20f9049e584648b51a9887b44aaa6f44f2b7f34c7561f777b91fefe74ddde2819d5cc09baf7613a163d4bccb9c2518d7bb5a86c7bb00ac3656f0c887e0c812148add52ce397aa57517b46db678a36e4fa1d6030147107c6d13145cf3824db69499f16fdda28a2b350768a73977a60077f5e92231d765138af65597cddc94e9412b65cc41f14b808bfa17292e814f5bafa4f60e6ed4cc252f0e3a8e3d09de511d7e3a57645cdb89b72d61b8763cb077443c3b0183d5500c921c0fbcca3df79a62956374ed187e8aab77797ba158d6669de79a919acb1959f871df02f03c19e224dccacc6743140a8f580364bede390e287c1985f6cdd66031a92ee01c8cd3faf0bd151dd64579417e8a0aeaf27a6659cec67a7cffa97262bd229415272b2d68625c2247d21dc4300a25c193784a072c94512378b457982485d2e919cd0a9386dfe0520e5eba5c64c424c844da2dee10c9bfa92b35f6ecd151b9d9b0e8f3ee1cbabee22256b1666df173a129169913aa22efa53ec0b635fa34cae3bd96b8d2a817ddf69797c8cd3551e573a13036d15852d117843f19eb5d56411bf346464a36feaa629b7f630711a70f97b59838efed56f167a53b053628e30b72edff0a3a704a36f86b61da2954806723c402270500bd63fa6b31875dc292d0adadedef438900311a4efa6402811ed67ce195bb1b8d9339eb6d994217b134cadf245da1b7560bc4c10588edfe36c0a7a9762caceed3a33c90679fd83002261a437010242d02b2605796457dc81f8ce994dc38587ade8a12290aff351946775bb483c67f102c0ba96ade701f022e3064e2b022e663ccc4410085a5621a31dad8dd20bf18d5a5b74c63c153de819ac8d9397d99c3817f37067e3d02e8678aa213c044458f9b97976d1b8ede6b3d8e095c45dc4564bdee2ac0bc1a980ccb34dbf80759a67e629fa03b0da37494ee2763ade521a9e68c92b28310486d0c730e9c075bca9949c095bf7a98cc643c990eae03395c3b88a6f443bd4a36141f0fa33c898951c0c501c6028e22cb8f04e43d720a0ec922b5b629c388008670b7716eff7fd27a78c8da5fb6a04ddbd9d0a9cd5d6434a8286f33d076171a1624ec7bad64d8960286f62284aa70c6dabe759cc82136bfb285f08a38d2c0f5ab1a8dfd11d241d76ad4da258238d4379d3e8b47eb3796996385fa701ad36a45647f236f1391cdcb3fce6e72def84fe0a88b83cba75a928fe0d5e9411f311fe9fb50abdff88757e42c8d2e87884198c9cc836a16d7f9e3172389a71b5bf1bd6266bfcde6547b460ce64eaf4984ac898dfc904748b3b660f9f486fb462e8e2ac59e1e9251d99e2ad4027fe52a3fcefb3ca7c2d56792db46b418dfbfc7e1acbb108191ef51cc4bde743924d6648dd6f05fb808de61f30772e87de2a01eae10bfc416c09469234a966a4e7b5f70e7b13005e4b55e65397632785db4357f214d2dd5eadfc03b09209fa2d0d850603ba15c51e1c5bf79ec8707fb3e98eba06e8f7099e23114001454309c8aa7dcfab44b9b4e80330dfc6c5e91bee85840ba6698a95c11dd49129a7b5f73e26c54c747d33beae17f4a766b66162206c244e82aefa3f7b8534824692795608626d41ebbb6e1b3cd7fab2f5c82d89f36dedb922b0bf7b266be9944cfc519c3f8714ad81270a0194014658c1409dcca0a8f369e68425295e9b7f0d1fc405aecc1cddfcff40deec31a7113c35efcf11d2608f254163ce73e96270d1cef8972fc94b4d8c7c11299829a4bc0c12a174a4690e9a4c27a95988e39783affe20100898b33de315902e4668484d31c2613f10b860aace72dfa25d42cb99837a04ba013092b2617dcfdb6753a11e7ffca72652dfe76e9fabec718967a9789bab5df76b16881873082020a0282020100b46ec378fde10840198ddc0e0da48f8cecfa6b3a0711d43e394aabc108eb5738c0ee30d09d355de194026bc3f157da4dd70f942143f429c494c0699143e55a58106b9cd40421af0aa4e0b56f736a8d154c39312a5cf638935b929205096a92e7682e091e1d6d212e25f025b50382029644c0106f2ec1eca20c4646ae0b005aaac45f6acd7caa25aec4d133621c605ee95de2ff5e5d70327dd66d477f23f05b9c2f4229678cef7a9cbf478fd73463297ee22f95dbcfa348d897678e12231a52d9f5f915b73e85298bda893aee1493b5ab540c7dda7afdd02901c8677d88f4a667f6342e83e307048c12dfc182a0fd5e766dcc7cbe99766d66e0925ebecbcdfeee4f6c337e558e4d1e9bcad3d954b175ea83fdbff3d5e347b195d4ff40785a825b05267dc776ead4b4923c1b34c4a3a9593b0f531f7a4ba2832486ef2e7dd3f1c36f352f6698d0ed9cb0b55177e5cfbd412353085478efd2d8446406e09e41f209c29dd6200033ba9d02a5a646f99a68aa2df815c9b82db5ff3e3091325a1052a9f1d3d8003a804449feb237c5f6fd99b72014d0d2d02c7f41a295689d00aa37aedbefc6ed3e223b408cff41fae57f0a7a127db4f32f9ccdda29b3774f93f778af99d8815fba26948dde427b27b4a12fdaadb355eea1df66cdb6035096f56067d2381af49aaad15c1793536bedf77856fbf04b4e7bdf67e7c15c9ed3cd832b58e102030100016e636f6e6e6563745f737461747573a26374627358c3a6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f696458200bfcf258e0605d6d00c0a05f8caf44e53ea2674e41901efab7b0b48083a1f97a677369675f616c676f4d4c2d4453412d38372d5053333834696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f6861736858301e23f873fb0cbae2d67ddddf092c3eb1c68f2e17116b3a3552ccf31bec457f1a3e59aeff4dcc81f94c19cf466e18fd36697369676e6174757265591413781362196be51aaef7ef1d1b8bb363b829899eca726d026c0baa4cf7916c35e68d27f3d1712ff494b0a6dacc08711ce351ca932968f2c33e4059535ce737a5eeee9c3203501289324c09f70cb34fa19b6211a4a1fd9fd3077c568276bf9c475d7aed218b76afca43b46bc6976cd2dc7d886a4bfa5d8d2357b5bc4ab3780928c033b07799360407a197a9f1046f1c1a154c8ac9fd6fe61a9ab8ec3fbd8a975eeb1e4371294dce39626bf90d3c658f92fc34886347288ed279ee721babe35bc59edd008549832e2ebfefe0f55ad8a2d0af749fada1da13cd932842fab3cfdd78a36c0c72a90231876aafe8b1a455001e9790a10ea3be86f6a55a2cafde7611ae4d0301b675fad77e6d8f7e69b85aa56c3dfad9f3f99bb4fb4f22adfe7b0ad34075b2e085c1e7b1622238a3e4719a972566dac223858048b00a17c8080296cabb9e929668914cce5281c529be6921fb03cf0ed0ed3165fc788b1e6592887be93ae2600c47de694eaf3f08448d8d1d3acc2b9fcad5d5646198ebb880eb6086ff98e40b05ab46c85c7417069094705b6544859385aac61e6fa05590aabec655c67f06db233a61fc02fd14b5e24c7df8ddfda88708db4d99b831018d7f8edbfcb13a48ed662852369562b5c0a64b2e4884ee63c20e6a0ef51254aa736e859c34d48d53d8cfa55e2db2943ba7008191d37f39fa8f61f8e6b0f869524292e5744393415e44f5bb102aec75931966163f12febd33a0c572d92340bb9dc9b2013d1f9b39c3ec196b681595f9873cdc4bccc41c94bb1e6be34aa71ae7fb9d611938ba0f89fb3bf7537a73081949251ce857912f814fd6c41602a8faa3acc8935f97f37264a2c3e6c309ccde4e7d61da860c94fcd91c17d0ed2932e90cc289f69b65b42de966cbd9af884b5f10db739c7aa27665d16e9fd0603d65b289891b18df8e4d74e63511c054cd1473de9a8f3780ad015840f5ea690b14a5d8922ee4ec85c14065a8455ff3a66a86f2bfa59f11c5cf552c651dc849403cf225c10e5d40979023263c71ee6b69bb8fa4765cd54371f215f08351492d465a4b2386f1f9376847bcda749aa7860c5d25eb680579aa3961ed478f9f59648eb6b5c1e0f6438d3b8fd229184688ddbf5939bd007b22950ad4b17dab77412d9a6588fd2bf566265ad6d1fb46dca3d72fafa8b4103863a85e155c8fa54c9e68c51fe714d8c7ac4b72a39c8231c507028d48141690cbce07528ba5d91b9e8aac05d4b9c87bfe5af1dc4eedd3ae42c23b696cf2b3456b3a76b6e5b15c764048c0d6ae85551ea807bf42c85a26bec1e64bb13975f91646c92088ae0e3808e507a8d17ad414d83f399285ce9cd864f66524d8c19b22ba0227708471f245a2ad874da9530e6fcc19c6a5eb858c366d53c440d55a89149453bdfc851dcd8fd45b0efefcda4bca9c95901786ac2556450da6a2b70e9fd2cec2f71395a92e541beabdfd2be4334cfa24adba8980d4dfb162943d408aabae91a7cea048d4c4801e25c5bd59baa3e353d5658aaad5e368fc441e1185dbbee225c1403053bbb9f050caf638f846c25445a8dcc286826a74ca595844bee83355728fb537c7af0dac7259f8f6b950e912b85da148aac0ea231ebffdcc9ebe9bd955411dd313f628de506c2b1cabab44bae3f9a14661b87a4093045548c907a6f616747673e73594ad170224b835184eb61457e512801ec20b283af1126a56748c4989b1d2bedabc282db52fe50ff9909d5fa66d01c7ae66ace1b62dc563f7a5f2d86e589d9c68ee0dd5c9caeaf3e2c7d05e0871db1f4748c6f26b024abde8afb247dc0455341575b264b89213724cc1bf57e7cc4c2de8b2b2a1706f364f846a53ff560c55bd2c41ec8544d223e2f010f430b5e3c90e1b6c5ec26dc17ff455095c5fbe68890da86ec91f57f621d841f0c0f7eb6e974bde6f453b184a1ec7b5119e322643dbb06bba65b0ae321138d421badecc0dc1e395511285751a006dc936de7314ad5943b19eef7b1efd5d2c64ea9141265fed4f77602a964ce2799a5dcd39c036bbf35daa31fe7e6c6514a3e19e7d6e454265a11717ed14c3ce35f8c7cda4043b0161e1e98ecd1964bb5246e2cacf6fb5ce5a65fa539714966fbe5710db641874c8c4f404d0ed260a7f369887ee177bd8200f4d7c5c358d4e036b4df68880e3f7d9251c54484c09f955f177b3ad69ca6306e4a257a31aff2e08c0cc9fdcf7c0c8bfa286789b4922f71138f6227e2a4a157630b65589337871b02047681ca82813c16f4fb7317a3a06c0fd9bd59dfbf0e0c1a55ef63df2c7e400ac1058730cbce97ec8c79e16d733a3407034c6deeaf612d4ebd7aec59b522d4fab3ca33a735249d4f28822b5efa046ec5cae032760d97da7a00db6d743289ae9b3a8712e78d7755950ca09b8ca8ef37c6c570a48af29e50698e1c125a39ae3ed2f0d0dd646a3b9f9b33a90b70f32fad2e0cd45372b3c8c51fc4df65fb2919441709ed962fe8c4b3418679667cd4b308211e2b7860b95a22186bac94ea002440b5a3709adefad58820f2d9eae8d0f76a872d411160f4d5b65fb2007acdf70701b22254561d8ea8e940e8d94c3d09184c460c5956439ed7644190fb8404400a242fa0b3270fb9be58804b38eb60f81dfd211c73f7f4591562b48d76442986d68acbf9337744fe3c47885947153e332e1b92c3d672c83fddfa73e25322efa0623d9118455b040389cb1158657369d6b466eeb9585dca50c161963dd19f7f9656b0d37db0595474b0ff63ecf6078c7db54da9f34656aa4a4fa28615083fa851d5f07e31880e6145d9dc31144e03ec533becc5c902cea653069f89231b7f016d6fe0a96833eada8f6ffd2d931fffcf5ace7b3ce210e5b20146097705a4329ea1e5eb586645c7bbd7c11468285b133fd316884350fe4caf6bd75f8345c1f61382a312bcfed545c566b5e83a0a71b694334e7842149d5c0f32ff0965ec0af1a0215703a66161606ba4d539265def4e887151da885d392f6109071e13aada0a54484102dc4c39f7796b3249d2f239cd748b10e39edd796035bfb608b319dcdb7b729c88e00b8c7df6dc905ea589df7c34d555328eeaff94d4e059c931529abccbb4f99efd52428c251ad240002f34f3c3a393cf2e6d2312e81e093e6d04e8970175cbd3e3f3a86c5b64cc165ae14bce8a90ce6efb8ef10627b33ea2b1e3fac25a189d628de888cff796a89bbc6b343e1b6acfc981088f2553b412101b23c61efc115cc5fc28ca6a4dd8dacbc5025d7ae8a4c617761e5ba90262d17687ed9188f383309bacf5585d552638f0dd3c2a84b04775e89d9e0e5292b060f68cf86011959ad191af0cf2abad2ce01522307574e8163de13b4f6e5b469426b2f79a61f92439df1687b68bc8dba81becbbf52f2174006c7edfd9e0db1ea3bd228d3464a74801e08deeb380d180a1b3c75f58eebdaf550c92c364364da6583470e529ad413bd514e82669081522de297a39024016e32432d6290496cac00ac04bb2ff84891552d597d59b792577b73e2cae4168ca3640fa8af4175acbe5a35ff979fb31d2ebd17ab240dad785b587e82902bfcb73a9280d27f09fda79c7f929cc2c43434787949187bb2790f1a2441ae61ad809bedd451168b0c56cd197ab0eb48ebeff6b51876722a23b5c7903e32de6e65e7448b8d68a7eb65c292374af350e06d03ba6e078cb7648896a5ba34a64f6735697da7d4db0763f175c4b75e878793f3973e1dd8576c7ddb6b3a0c4fb0ef53528adb568ab210d718a8ece5ede3da2daaf80605b996d9961dc3805b1a818f381d55ae5fad7a4531cac5ec865f14874269c3819ac0f6043af63156fe52b7f5dc688459db6c2e2f0b2b94a772335219d6d537ca8ac46ed7651f8fbe83a38d79168ee49c67bc53d48e3ce4bce4b702012d304a6ae43e5b965adf1d69f8b12a13fe3689418cba03a7ff1d0e07e5b7c311ee2c7080ff99c02b517b2521279130a38fba515c45adb28eac43729fa0dadcf7fcb4f11c36d4e91de35bf3d6cce21531afe32e16b273ae96d8f94c63d69068a746a202c4d6ebb76cb9bac8ada2a6f783eb895f840d8b35eec6d4911d16c29b5da65baf03d0fbadb2c90795610a609eaf947eeac28bd4414a383560a2a3fffd314be8f22ee9b3f8bbc45be42f8258d2952a90a0dc0090b738187843a1549961b953c9f1002899417b64b0bded5f02e909aa7541c221e15377b432d7ca2b4260fb3414366a8ea71490da1870022755243480a3047f7db5ada3add32433ecad87791648ca15dbc26892931ac688bed1092f895bf280e981211b4867256f8e9203bd0dbe1b5ce7a05fcde89b2d4de6dda882971c18c3c2bcbf608f777fdf4d65647569f0815662d98d96542cc6ad7085a86425bfa4cec82770016e5d5eee5a74e546965620abae8824d54c07135cf7bb28d1c6ad9eff08716e2214e1f7a9da07a2e76ec36841fed388287d189141c1666b059b27e8a3685d8e326c1fe0b8a8be066894caa0667e5e449e04f581eafbcecf50bba0ff326049c5f3599b4a29f4dcaa51c872d5a995c3df406bb0343426fb7d3277a90540aa12373c2ee6d75f6670d483178a671e2afc4845225eab7a221b223aa500b99c35e6757f118ce83b5298e55cfcbbf59b545cb28bda9efea6f4e6e4cedd3ede7cbb4b7e658a986c11eaf708859d63552b02a6468fd2923c688099a44eba9af5c14afeb1952d7b85f0859ce5c75a2b999b46ea65dd58617457035ad1c3d2249ccee29a9d72858fdd08b5cde62ac1b1994058783360fbc9a5a3c73c00ad8b5b6628a0c52d8692b1681948a620b7fdfe9c164b56d8c855d4439f67085147e32207f58c7059351b8f393a398187abc493b2475f79ca8c1b03f74fd3cb140e0f839bae3c748047b053ee94e7c15a25e623c998f26eca5e523048aa933b3f7d440877e85448eb62e3cc1ce9248740cc2ada4b54f1f56c456aa6fc02a99281101b02590dc027ef9f3654dd3c8822ac837d394780271b0879a92080e6140fe6475faa88ed9a5054610ccc81929e812c432101787e3acd01248076d06649d54b3bfbc5e2c3e83fdc834d468e6205ab85d13dae313264fe37b71a3bc583ff2f36acdaa05c1e609e9a39529e69f506586b768de21c2a1fbdbfe707045252c1a404300ab7f32a673da4a820cfb2ca82bd362f4aca950c86a973052ea46a36e0f5f8c546c71d5f8dffdb350cd072de1ca3f113b1e970b256fbe4f2085d0237fbb45873af66b4c2bc2bb1c550e88300b2fa2142b13b22fc76b2489a28f1e9c6ef7cb65f5883a885bb4041156371f4ce65373eaea244bd10174a0db2046400d62c2642fc521d422069c426ca7a9385689f76b18d36886f56fe318415c10ddfc06409fa7755376d3262cd16c545648db197125f0a030250a5831e7f35206f3d0b1526b6fc385425ab83c02fdaa73b73319a4e593d7abb8be18da3f3c015f4d10a2b37592b5de0214c8c7338958a8773d12f208f44714865cfae38ebef71ce53a4a8dd38e4466e30171c608c53fdbf8729fc0828025bbb897911f0a82f719d10113aeb7b816c7e3e7a8c20fc56c466aa4308b74a77ed13416722e8176b7c0558366bdcbb93c828ddedbdabf40d66b5d2e32c3df8b1fd134e40e6959f9cb8d1fd2fb4496abdcb53e91c7bfc947d127571607676cae57e98190e31a1c4db0002ff6b3785d6b41d5d6a50a96d1dd94600a896401b4e0ade8296d910d0b39799661b6b2788e0d055a5ab06212a864a0c67af9d9a2e0a9e1fa1453b1951c454c6407b85ea1badd38dc7c20a96970a9815c7ebf6e282b631cc43b1e23606c5061e4b927ae747796dad4ebad24be3b8d54b0b83bd48ff20c76043ef4a211405df9b79df108fd9651d386a0f784b5c12fc6765c5733f0f4137ee71153bc76b5b9e3fe78ae162cf6810ddfdd164c0dfd34b5d1b109be6ae551480aa99f26029b42bf50a6dd15e994d70a3a172c45806ea4057397c75cb0b9328f1655f289c312716700b52ecc789b3a5f4de8c1b08148f90435b9bd1cbc18a3c7e8972e1f6a6224f143b4f7c84e6ee0d34078ce913c71dad57911f2faa81fa43cf4ed73067571194a4b96b93aa62acb11a43cceb637d7153239b50ed0f4692a8d259a893b53172348fe40bd3c836308bec33d612203b5cec9f355c51ab72a741e539f8ab34b23f2a86533fa1a6a873d8821bc56413c2a7d3bc5986f724cf16aafa203e26f16d0d9f2399517b04e1fb5cab575ee1d66f35287901f20abb11453c7e37989343cd6e64e0f8376f049ab94ba4c6b85dd4ddfcc53bcedf5ba1305d7fb867e2b606a6f58e88629fcf5d5501877984820c838f4e55badb224ec4e79907c89199808854de017a91fcade0bf9e7de6f89e480115a2d83c93564d2e6f9e534ccf0544bc9387b91f63a6ccd62a323a484e50e60e115aa3c4e0e2091323abcb12234c590933344b65b5bfd9ff565e5f6e7ea7a9c4d7fafe189dafc0eaff000000000000000000000000000000000000000000050c13181c253036a65d20b8c5f68704fabf072cdde757b28f867dff977ccc668f49fb45a809eb11d86e49421ea8c08417e1db5706c6fef22af0d03b66aa1f9ca7a837af9c61b0409243d719ff1376c581f3c582e26494cf13fe68b736ec04476fdef859c3cf78d1b5838068b9f4f0a84999f87895d1a6be00a12c8062595d35c6961be6b681bb11bfcc1e58a7e24fa5afacb38e983b3aae6455d1a7828564da1b8835c40e1b322fe5f5f7dd6977e571ef479693f4c39f559f9c512c36364db9258260412d1777aee36c81576bf73e8b1a27ec5133e607e55164e81d04c8ffd66a36721067b51ef784159762e47c1315e69d73c3ecbd46a25d0e8cd90d07c7a1aa0bc0cacca85d0df6adf2242729efa08390c86f3b26faddad52876055783f89b808344b28b4b208fee637e69fb5dd727c4219f56210cea9ea84b36e5bb3bd82b1fb57841a441e4ce07d612786572d1e54ec3b2e14b0d2ed2fe050c86c7fbc45e2654171b179cb0a1eac6dd722e6765277963c1287e72e3398b1808eccbb6ffa3e28d2d20b6ecae51e7068ebc16feb605cdbb216e58f765abf5eb7191022a05f9b895cb1c32b4f971c89e1cf987ffa895fecefc5237795f8fdccec6a12f2e7eab4755f3754c64c6ec11b6b05fa0b5e61f8ecd4aa2db1d6b13a1690b2522832ad9962f4cb6cc15b31332f565a002edaeaef660a15cffe2f0d3a48c9804dc08bccf13e3e476f9193da6f636f6e6e6563745f62696e64696e67a263746273590106a96375736567636f6e6e656374656c6162656c781c4d4143554c412d50512d42494e44494e472d434f4e4e4543542d5631676e6f64655f696458200bfcf258e0605d6d00c0a05f8caf44e53ea2674e41901efab7b0b48083a1f97a677369675f616c676f4d4c2d4453412d38372d505333383468686173685f616c67675348412d333834696e6f745f61667465721b000001a08ddbfe006a62696e64696e675f69645005bd2508eb8ea1ea6fd96a45da15849f6a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f6861736858303fc23b4923d093b05ed521ef378f89b6902a91f052aabeeb9ad63f7aeceb0dc8ad046c7f7acf3db70809c4946b97526d697369676e6174757265591413744582a82637d9063eba827089d6209d914c3bfa9f55281abdf5789e127f4da8e080064accc07d18ed29bd072e129387dbcae711dbf73407655e592df250f6734787576db751e42e11b05d22e8c9e36c98145eb05c2c11e7728fdd8bf1df7c08eb14a8b4d157e062a5ad23d2f96d962071d686a84b8ea873e0cf2d522184c60cb44df69bf8eb70963c862662b076a53521099254096e8fff0d0b6d195db9abfe4cbbfc6c627cb4b74162ba1b1f845d8268dbda2df704bf31acfd8a5950168e33f612c57f49bf3dbef19766a4daadb3911d95e54d6e93ab189b9bab50688fa5176b6dc2cfea3b1185dfb751aa2fb4149ce20602ba9f4431d406df40ffd550ccafc83ec9dcf286cb97fcb7267c2d060aa3cfd223bce3a467403fe51c92a21471041b7a2c9f230b90b1549db06cd9d2fd31dedb6791337d3a68f4f012a0e573413d147229cc1200b7bb4343152eb0e4c8a536ee3ba47c9e1de15f812dd372373d4c5456c9d54976438fde913228cf2bf55cc7578cde01c55a441e51728a2391488d68c6f961238773e8cfdcee2caf72bb73de69e609c936988ba3285c0b3b2b20572ccf829d6bf5575c2ec84908501cc742f38afed17c8319859dda5a393d2759d5d0a49bf60c1bed701a0b821a835514b55f9d5c387f290840b6d12870f00a02823c31ba0668e1c2982aab4efc0a81afadf7688644992e8033b671cf01a6c344acdc41a87bc5c3bcfc0d09f5503a8742f2bc704e60e5fb7c47c708f6a1fffccfd127443a80ac90996fbe862835afa190778dcfa447ef34d91518330e79c3a812b05b781962a8073c3788f100a805d642a6b01c0cfb8eb10b5033777f3d0b325ad9f91652d3de407458bfe60d1142095eb4e07fa4989efda5799986d82331349c06c7dd640a8436b66555666fbaf7c858100e15abb42dcda17f0285e1e59fc99f77485ea62094b49546b6b2475fa02bdc7fff64f0f553a7074abcd1c0b4464f075c8419aa6980476f23b019e60e02d40a85aada5ccda257872a84771d1c7e07df5186bf267ccdf518e0b2e8e569e913a7494504950e8564f2e2e1772648338b2f00c97c5d3147bb54855295cbc765e399e09f7125bd9ce2c69e9ad21ed757e74f70b05aed9596e59d4fc3685cc675f2416a76880dac4f99eca2881e8e18bae6e17f3b8dd08bd866d017f6f617bc888c9b71cb2716c064487c128cb9fa807f6d950175dbde0b84a16c9c1e2189679d502cdcee26d67e4a023fd715ac042255add568db5ac24fd9b326f0044eb521741a62a3c08a8ecfea8c90d6f194b2652620728a7dc6f92b88fddb0f3ff159690719474c3367f01d8c33144fcee87c2b84b23692cdc9d859a6b2c6dc3212d968f654168baee3e9d3ce904fc3f1cca787bc74459cf718a1f838b6408fc2f5d2e70ec7445221821785b364e9c994dbc81956f9b6bdaefb0cec6995a3a8818d88f602a289fba8c5c4b2ef63e0bb99e4c51ecfe3b9d263f23a45bc2144c76f74cded70b922d2de1d518716562e0a0f2aae04a1c69418d1fd01345e40336c62250cc7ba92e6ee42f8045a7a8ef2d3d30351f793a02a32ee2ee6cd3142f62e6d9bb1fa134f2aa449b59eb5d45754e39457cde4fa85203ace6559a9da44957cba58cc44f0341eca7fec791cea556899b10d6c24b043bcd891f0bd9f3f5bb5561ec03b5212fe455cf073d5bf1351a63799772d0e1b8116fc76259bfe5be0ee07da56cdc835dc809caa846a5a24e5f88aed167955dda04ee6261cef9b232f2ec288fee4bad10bd5e51656827aaf5cfd41969c66d9083011b218ae016b0db439969391e285368aa7e9633c6dc23db8ee64ede5b69eb1c305d2b81f51d46723455eb76104147cbd720b3cd8ec00a51777d0942185cbbcab0b3aeeccb7993da36e9fa53390a84889c66cab27dc8dc6e4375a01f88b4d5872064da14618d864ad7e70573af110c7e9d625c811a4827ba223afff0d43e8a48eb1796dfcc273f3ee590f2d1ea16a6cbd8660694ea2966bcbe746d964b9371ef46e5823a1e0bd1fae7139f9abf36d2d7f5eb270908061826803090525889db71563e3c3be57656f85fbcfa541520dbbb64b8836dfbe1ed109bdd4f4458a21b7267b4ed595a8018e8f0736db6be6e874573e13238f546224e700a034954879d3e1d5d0518155c94b95c233c7f07cbac5bf66d824eb853cda4e83fc0a82dddc9f8a4c126028e825df9c920dde37ce5266c9fa94b7406dd3eb7c59c6b1310d4ce44b82b24b43345f6d884958e9969124cb3d27dd2a4cd7f9b4bd610727c50d7fe34c2ac01e77b3fe451cf3455fed4e4f090aff1978bd97243d0890c50e81df0237555686000c8001c86bc1ada2a741e779fb90d89dab139bd94ea61f80de4fbce1e9b791111b9c3cf2b70693d2034288661aa753a5a860971b76b65125e95d0429208fb77954345c29a54a61cfe7442612166063ad043a624ed27912248d03b81e2eb5724360ef25393cb8aff94470dc555f67c66b1fb0a9cffa5a3ccc2af9c67f69cfa0f0d938171100702b5a676836a050ec3f6e53537864bbf04afe54d45db2ea3f70ea8ee2499f2a0c2c8ce16eae6a16a49c948fa65555ca754faa186822072386cc0887612325c6c15549a1896c4b2a3c07295d4bb94ff3f1664ea60d00f47fc2844c756aa4b5e6da3373317a4d4bb47cad0c2a7f7822ca6f596ffb1b13fad2d06e93614df55f744fbec9da1b950dcdcb08e1684f918ff39f3000ee567f2181b8715093917fab910e8eef4b64627155b6530776263e1124a41e858f8e98c4851a6688cbed915f30ff5cb20cbc93746a9406382ccf6f67bfae04fb110946d0f4c6fd9d3ab0b9282074d05129855cc524063db6542374776dc557df8ba1cb5cb0892ca4088eef968f051a6b7ba28135ff2a2418d6874be8a0d572240f30b9f386e998a595dacef85228801bd99d9f824fc644cc42dd056afd8a19befd60e2e60f3d0b445a12abe12d3cff88a0a5f108d5c558ca94acaea3c9577c81976708d0a4de53dbd961c3f83925d76a8eaedb776f2dffc061f60cab47479bed80e6aff6656e4760336aca55a822110b1ee8337b098e96bc6824c6d7e06477b066625980a4b0f1375e8f79e29c434c751443f7c7fa53520ae948ee4b3bed8550ba24156728cff0049faa40504f6162c3a4192b722d620369388585dc75b6309536c3092147693328f3c64abbaaf420243d592c52cbfa4e93a07561f112129d44b661a271ad27b9b6840629d4fa3d0a6c63191aa3e3d2c30c09e3827d5a57312a5744bf775b2d832a9183941ab666dc29f0e039d6aaf8eb65f9236362b3c958f4aadd9597d5833d1195ed0dc0aa9d18615a02c95f69d54072413236caae5f920c6649ecd4c89da95c848eaccedf094a82360a15418e29c435b94fff90fd4e196016447e9cce92b81b94fe4735a472d1d014f9a001b7d195c0ee62d4f0a06d2d0ce073b3cba3169e069e24009bd71b1ced0ee6b8867077a5f3fb15891652e67e199cf78ad3d57193d5dc82f88cd370cae8c91ec9af657b17883ae1c16665fd3b2f488d1717f9528db1f098d6b3dfcf797e5838abe1ef4ee86bb3f1b04de57505561e45d8855ac1730f74336c566ff9ad52eeebb292945485703e3ef6b18ad3eb84be59de2b3ddc9913079d84172ce51acdf88c6c47f780dd12cdc0debd83e417379371be9e275065e0b49928921a7110b90e12e62505b54143015edc11cea0825b092d5647fc207f95a89106316c66de0ac2957383c461273f0e42ccdb9ce07022f8bb9be3f2523803fc3a11494484c03b02dfa92fd029a764df814ac23eabe1af29e6b26192e518121211da3322e5396b8875ae9bcfc0105e9df3c28bbd2f4ecc26fdef78b455a6f271cfde8a1665e499bd3c470200eb3a6ddb417c65710f3abe15e31b375c3f596dffc5db8d7a1b98644fd9e6930891517828a0177d5ded2f3763523e82ba1f52aaf73a0d29eb96255171bc6be4ef5c15aee3511b2f253ba1dfa6d914ed0dcbeac22469285378c1558c7b7816444613b868750382aca5abff4eaa305e3e4f08c3779cd988e0fdca45ca988587ac6638424b0692ad36e9b3fb2bbb83560900112daeecfcb163fc5a3a5b698066dd1a7dc14422d8de6154e8da5d89ed40fc968bba04834e9c69b3a84182a4bc11bb2e47e64f67bdc6dab72aab3b52f719a5ff8227328cd5c50412837f3fda30b47c0af14a326ec8652f2b9fea5b3e59df73dbe0dd16a2a87ed3455da165a0891813c69002bc7badd2002d13452a238b0ef2416160dccaa99349a3264fe5b8664f5f66c3ef2e92f6bf386eabae5faa97c08f917f252154a14f55e0b5d9b7b084df7e5761944c3595987947f57666679666cce3b00311c9c91187ba5f1d8111353e8ca68ac3e3c7fb15904119721b7c43080d5e7f13d953708895a33d69f79af8686f42f8d30ce72354feb0f007fa5881f041c69fe38e9851e94fcbf97e7a8c85a6066d74da37e1ee0e70878291b38cec0735526b2d9a5689483355367f6db26a1d9f3c820c7e0cd2254c4c61c0a527587b3b549c53b990447165bc0abc12da63cd1c52531cc99ce1fb72cbe6cd6c97bc710913fa8cf63b8e0eddc78a53e1449d9cee2d2145a402212887753727d948d62af936b53a08c4bdec5b92f5b6679a865ad25fdeff38410db6bd5e63ea630c851d1d964ce97d6f3b1b12e1e9aea73846eeef9bb6331173ad0541b71059def1489d6af6e719c978ba3db1ddd0ba82707c11fb6f2a372132ada97bad59fd7a622be7850b02a356e262c4f5adf69016f3a24fb3248af14da94efd0312b260832927f83fdd76cef866368b20468bf1bb02995dac85e655e9afea8514534a5c68e2bede91c0b4771200e6c412114ece3bd770a2a0fc72cbc0b82304fcca6d42cfa23e452958cdfbb84f42a87790cbc404a9109fbf9671cf2e30eb3fa4b6ca525996196de4114d5b444d8336908f95249d2e743760908c8f4b93fa96d17681dab27448e4be6953f2161144c03c80dc3899ebb09bccde539b5075fb8a7c3acc7089995aea0818be411aae91494e37315a17035a3e97a292d0e53f02bae82d8385b58b9d19f6a3ad7c2b69db7832efbc75d0c7abf377559020f39264a3d7dd6d68fda1b6022fc501b6644d4698758bf40f4869db1683595a5591a6e19ca6c92ed3bf8d7918ff657dc5da39108f9b9b58124321eeef61fdd9a9f6f1651155d582c9d88d4670e377099d4eb0cd8041f518555cd3ae575739199c5790d8b14a4112597b9e643ef390befb56e2a151d9dd078cbcd3ffee0cc9750f5f5843610f3defff222f949a0047534d158ecff9de5ec5068f53de58741b86cb910e57ccab06d3766ec6e2c745c351612ae0982140fdb992fedb3159c734fb345c07e993a68ade555ddd86e556693eb2eee1bc7a6e67270be7868c5c21156db0e313000b128da4121c96034b8aa8490d288dcc7bd5364007e0d1f7cb1d6954f44dfb31cca1820b2fe4c0af5d6b4eb1bd16878ba938df5660873e1eb07460fe5ccbfb5517733f52ac8326ceaa868655cc17b53a47d5551235fbc482c9f93994aebdeca79b9ad42ddaf93f60ad65fbb9673d3fa0d441da9e57ae5a35658186cbf3d6161361050bb452a213ecc394eea594d41436eacde5bdc38d89f2c9ba7e14fd5c294ceadb28587edd31a019304538f1a12f732e357629952b56c5c3531b893974733881f3b72a8525514566ade8b4fbbcb568ef0ca4f36a6bfdc47674be01afb0c446709d2042bd7fbf6a858e40c196089caa3b8de12a93aff943eaa280d9a2a8fc0cb1480c4b0de8b29737e6209ef513e47e153b438c6df61be0d42e237bd2bf069de02569e2d6484de35ade6ec866c2a47843b82460ab8b087cc66eee5715ce770e678869c78e636d76b3a61b0be46c23fccaab5aa82e8efcc40c28f451d39f0040e1431413dfc5281d6f55523f28982383cbbebc8ef91181d9422f65e5f161a58e02e93041af38771611373364993bdfaa466f1618b44c9409c34ee5a04cd78591a2e2ca35b547f0a82a48167d1de3760c7b33f0d2bffab3d799e1f5845e7c7095d72b979863b2e20ff2a696ac23320a8d4715dd95baf75912b36ea52638c71580a2df99f7f020c06a8b834e5f7e98c969ac56611d88e3879a406dff9206f60473a4d2b555d893d2a28ef724935a4fc6ce00048b98f8564baf9cf58327c9aa5372ae2609ab5730f1fae52b58752a8b1addd7882d6c4af6689343f7ce81ca523e3dd7ce59cac4d881f39f24779a5d11e8d1c11172a827443b0707baee0332ad4d86c17f629e552cac34d713eaa7f1b80409f9c94d210ef27b0f1de54c380d87a082ab947a2eb6bf651365bb65afda982363ea2a312c2d011f6905fe30f657c66f8f234602cef43d17b7b9502b1e360e102852596badb8bde5fb12506c729ba8b4f3222a79cacde0f1f2f83b414d507684a0e3fe5d6784a6030c283e4c82b4c4c9eefb0612273e58727d85bfd97576898db8ea000000000000000b131c2529343e44b007fe359e530fd91fe31bd6644f2b9ce864b38be60b5f5440e145b1c870cd771487188a139321a86d024ed29b215440e5532ad97caa1a3310d271302a374d8b8194df9ca5eb1deee097cdbdee6861569fb965e0ac7bd58ce77c16f315fdbd2391913f89145af6edd3b83328a751137f2ce83ebfbafefdb47414df361f281881927b5acc0e669d811339b2b12dafe714825753d69fccd6115349591e30c4f7c5543e68264421717ccf3e4be00702d1d1b401e625135095f855f81acce1683114a1416e2fe4952ba4225a531609c05eaeca8d00cf6dda6869b569b4384b724fec619981920878d6372acfbedcf002700daf984dceee4466342728bbf8d5c73642f2d2772b70d7b99afec3bf02e86bc7af3ebc55a9fe990fe2ba8e9bffd4499b4cd703951b80e09f308b54bd8325f8a71da5d67f58a7dbc02a0fc5f0f178f96632716d6ec41258b591ffb30879476004ca72f0fd6e507bb98d7c2cfee852f401d928d4ecdd72fcae4fee0b24fbbc70c0b7081c132301cbd0a1d26155d7f041bb85544012cf5e383d2069dfa116dc62642f8a1c357e6033b8dc7de5eeb22b4e64eefa24a709b8d2a58521421391a0fa11d29a01c8e146a4033d891de1870d6002016619657cb1ad506bbfe9d40c0d1258c952bda2cdcec99bc8d21017e476dacef3e371097cd612cec41db0f1148e9aab1775e56847ac6b3b73265df7728c6cf21f726d656d6265725f656e646f7273656d656e7440", + "erlang_station_node_id": "52ed288e3ff39ccbcf74b438b779aa15e3a88871c104d578dd338486fef6484e", + "go_challenge": "a7656e6f6e63655820c69357ca9cd1ce7c4b31721928e10f1d91bb09b6a6d05b93036d9889b1e4a13a6770726f66696c656970715f6879627269646776657273696f6e046a6672616d655f74797065696368616c6c656e67656a746c735f737461747573a26374627358c3a6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f696458208cf46f6d3eac72cfa2fcb212a42924ab05b4f7a43a8a69ab91add300f95699f9677369675f616c676f4d4c2d4453412d38372d5053333834696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f686173685830b05247a00d7540f2e95e7ed900d119c88fb6d8d3aa495319eff04678531a4953b729843092b169f923286b5f2d397f95697369676e617475726559141391f93a75f1e046ca08e139b75c247f5e8276b490addfb88d2e4de0ea5eda34c9c7add299a5e2ae744a9ce9e702a2c473e9829f133c662be0e4faf600803610dd5d0326392f89d1c03ad864fd339fdc6567180a228b6ea8adb005cc36e01792a9b524c007d12bc2e19b0f985e021d4ec1b17a5e33a6da2a460e907ef9aa860a31720ca4a90ce480db46a3fd7fada94f9e612edacb5e2170f8ce7d31248234347821683798e397e36feba85367745107ef906dac34cee50875e084dfc8fc9707c7d8e7a9df6d2f408799e3769299556700dda10ecedea9a2eea203b0805a1b934ca554e2740381265f172142512c030f291b210631aa61d1a29dac504f3ac809579e5d19c72a1cf11f52c980af49f9841863c1715ca2ced96f56b36a7a7998c965acce8e1d52defde0a21ddc12f84fdcfb30e4f5772c9bb3b5bfbb33bba22346faca3d371b3a8088bf28c618f724f69801720c316f5c5a272fba9dd13f6383618c4e46960bc103aef2d3591b354cd8371f9c1d6afad55d3c230af5c71832d75f07fb08c09c18cdbfe7eb24d68d1d13409c625845b1c0bf6b015d75e0aa1f8c43d7d0026b0282cc0b60537762cc99cf3a5217f84638e79a7451fa5b8792f880f01f4f232bda7fefd3915147e6f362cc3f6f64bc84e737b2280ed082c89d2f6e9fda3b24348d563a4d16beb433dd26be5ae114e07846e9d25c087f824001942ca7c27ac8e0784edf240445eeb6b8e1e27a13ed099c5a852c890a8a040ae89d0ef78eba4bf6329e5f33d296ab3264ce9984eb50bb36c994561e18c672d3cc4ac47813168d688351395f55fe402f133d45cb9f0b47ce194e35d96ebd7bd7b64a2b9bec8379cf6f1ef6b2a6597e7d86266016e27ea0386867d50c8fa00f165ac285123763e72cd8a377a46209d68396f861bab8eea463ae4db62aa668da97b8eb647852fef32461006d092f0ff85cf31ab076b5db8c5320ab6050b9e72abca35476cd215daaeab286ceff75faf417ce08ea9d39b915edb25f70a61f599eea20fda048c84bd9fc1491045c0b49678358e0a5b543cde33fb6b727b716f7593231c2ea1d54315a4a4e528d7c5ee6190fe2d3ae1ff71e903f3bdc7388bc301a9298f2091196ab60f5814359c8dc68c51a96127cd1d67233475934805b9ec8e6f8272a183d4e9010494ccd69fbc455665c234fada70309919f5c1ff77d9b64c169f4ba5f1e73319d9a8408b62946e7340cd51dc7c5ad333956733df1a301ef5ec11b8220cc3fc4a0979f93e6b9a593ccac55d88f3441d217967c366573300319f3b0d502fbc8a1b25bc20405fd445942a1c3d9ab3cce5f64407619b740943e130b999c5f014ee443a9e1ee326a37ce4c32405378e69300dc5971fc900ce786b0de5427e7688927bec4c4eee82060c06ab9d61d7836ea6e725844812d50d7add7df5d6d90aa8641ebd0f6d742009be4e12550eb845a3ed2c5dd58d0cae1375cae0176033f515fde9db686fd43c8e783df93ee8fff77bdcc8c79ef9ac48e0622ef6279fdcea3d4d774895786f086ea4dd0626f4ce07ad176f13ec800e4f2c836e88601d017c9f27e32d17b01c827f2054acac6ddd5ccc8ffd3d1565142de0c0dfa61ca65d41d13b6c42fe79c65fa16bc3a7097a1af1bbc867cd079fd7830c7b0270d643bca930e29c9b1039ee2868d4c1575f1070a75d419d5222dec103d8322ae5986eea6adc33d46f3a817042b9ff71c5153027f252c6fc31e20ae85445f14b1c326f50ef2c90d0cbc73f91c00774f4ff49ca4b677c3b0d3fef6ab6e4cb537337c3bd6dc571ac503d318048540298b2cc795b70f8ce9759400f6a5bde27de8d1e2179e28140043b5e3f8edd6a2d9e3cf48398354a2710268fa752d5bf8841ec02b2d984d856309a98558e00a12fae92f5621edead48990cb995a9bbc4d0dfd8f90c107e6e9d9c8a0041b6dd9f8760c9de8ac8f524fc695f31187c637d9677039a7ee375f94b7c57d3b6acd0392843d8563be38db120e4459def69de88177665bf6e542ee6b7ec7dc17e45a35f0e2e751acfc1ba4abc90f39bfa1d4e746ca5af0d3dd21a81f64433fb7415003cd2ee358d5eef1826193b6c690852056227dbd417c8278dfdb222b4f5f216d4b4aaaf8b375a3ef5073863f27b6684c055b1fda0fe2ad039034642b2f3ce540d9cc3bf46747bb86b2900487d73a58507acc351b403a1f0fff3e126d0873edd6b4c72ecf74d8d785a62211be72148e13de9c73cfc18ce82da7b6274fa934a328f1cc5fd339f7ab40cc92ad7f4ef094f4ecb5c15f3ff26ee7f5a2518b9dba8508d16260a907221f1189b67936244ed006fca193bdc6f5c87533c081981e4a1a232adc5cca45a01f9d831152fecc00253e17ea8d2a381eef50844371ec66ebf5a540621eedb9297e085fef36f7cdc2f75e7856018445b59ca8cdd6861f6f601f90a99456ef430f6be0c43b85e943eb0ae157ee775a7a96bc8a4504f732d8547c4f7374097f5329c29222db57aff50deb830ea150a12ed76bf93388cda452647d32b43e5c4e9e507046d6b80aecff999f4ceb9c0889f28b480aef2fdd46b2e733ac8f0a9be5d77d62158a5e8cb1e3bcf680e9ab4a1a1ef16bf534746f70b292a30221cf89d0fd74a4805b85456f1913083ca8fe52cbc7e2889735386e3a5076bc284e2e55ed874a6a5cba769bbaa81c9402ab98e5b93fb8ee911439ec2d840c2a7f521161b6695857f8d7aaf48de02ab3e06686bbaaada00ce0c17dcbe931c8e3c6fce921798dc4841d152669d36719be6cddf94a7f75e59623d110e88fd5d412babcc22a115e9dce8b220747b7fcc21cee38e3971a17729342461bb6a39dd766341f49e5e520fe73d753ff9f6bf369681f91c745f44c66c71c76a71f5946a5a6bb3c06257b0dec9c1495d4d6540d42d8ba098e251a7d1b7f660bb603e3863448670213c966ec8280fa0199e99639adeab8938e8b67570d03ed11a5bf4b85a72267034d6427668615c689a0d8e8f6f7c4416f3ff714e5e1f8a5cbe520049539604976e1ca60f4c5437c2fd0ed1413a13051bcf457fce8c691859a74d132df4fd4eb6819390d1941d3b8641fd13e9cf4903a3745da55497571550b19617db188d95dce59dcf1da52430afbe52d6ad93d6721944972e4db7f2d9446d9d016b1cc62d484d51e1da5e011117227ce692325c880955cf50fbdd25a5ddd9a6c34f5d1a93797df97431549c2c68f469377c92a7837a8a6f2a1d74d223007f2c43f636e2bedddb9b8a3a964a252b73bfa5f14e5f69da610974818e29eb0f48de7d9a6a21d6d12f6035a8c4004b5e0ba71d0540d3d53eef15b8be1476661548be5895db8da4d1b6bde86575137cfd39c353ff81759ffec0c16ab680ce7468d05e9f493da2d2437bba36125df85bb09df2c383ee2366979765ffbbc2c85aa7103458f4385a47278f34785de98fc05652ebeda2b11b289987d872ef7e6ad51649b418bb64ce86c7af3978737c3fdeede57d6a2c4287d040a0536939eb1ffe078eb3d0a35c0a108fd104c6555596d9641afae4dcc54fdd8ed618c0d4919afec76821ad512db685dbf9d943133ba60ccf9adc5690bcd9f7476ad40a2aec781fc05722c7dc79814006f870c4c1a0f9e8eea91d1915b7caee4918cfbc53b21c843a50f6be25f8bc17bced48444e90ab8288975c70f796184946b58a451ea60bb6f5e7db598bedec80b049d8b1cc02a3e8ce2c5196753d512f5aeeea9a6c99dd029f2b9a2f94976b763b51e42d7f72d0dc20ccf30d61da12adc014c84901a27d0fac1c494162fa5e22b1ca70d15d995c2bb26031738740568ec032e1b185e16adf6221730565635d919f87785c52e8e39b26e9f790e2a3490fe57719f786012263cdca9d45c7ad6588b34e3d32837db61b7f4022b2275aa9913e9576b0ecfd22ca2eaa614ad27b47f87703b2d54594d5c2c70c5c6432579a780d2d2ba23f84ed23b3f9a0bea36d53eda53fd73d403a3ca89bec4cedb67714f50339c1932737d82723f161c4d56edf32577b3e8a957678115dc077bbf61e4fe0e3e04dd2a10263a8b1aba43656294f56950613aec9eca26f471811f7c783e8cf2f24ca3f60c17e7a2e494c975c007cdb3789286fc459c480aa2590bf7542e9ceb7a2186a7c537a5734316b94a9ad3186e6f783f42454ba9ce8c288b83f2b183963751fba0a1aa430159d29c9e78154219d35d626490f90331a16dab3abfaf6694fe9fc5db3460481e511b4f2e2a883442978da33fc8a100a7204c78976a3f8beb148df27bcee8a09c0bbfc6e7ae57257be213f6de5d0c0356a3769949e39199a2bfeeea9d8e692791ad72bc4306c5d5883107c47939c35671324086f8a099cf13b1f704ef6c8ab80e2aa8efac452ff149e8b3c16a6dc87f0ceb1290daa2d8731735c6c8a5142a0c1a808b3b7c7e4659d7f4039b1d9e13251f23b8c65609075107521b4da42dd1680ac2009d9b8ceaaf91a73ecfe93d54f6db79cc8170ef09c515458c185239dae2184ef6c423cc8f5047bcb6faa6e1c1d64d5a40f2848aaed90b53f163e682cb104ee2f7f60b0568904f71872ff844050cf2d7ece958c07de6e535b2f9f54d29099171cf7d2b8034d3a59f7d590433367c2c130d07fb86e6b3701e7637cc809929a4a18db9737cb5a71e7ff88337364ab737e9f1f594e93bf388993df549f10f83338dbc95f643f984834a01fa1f39f539696d8099586ffdd0b7b561a1fd6f3157f45e87e189eee1ecbae1425d9a0df96f8a48ff5acffc2775bd1b8dbf7f2eaf3c137d6f0828576ce19df11f1c18e65eb79aeecf1efe01defd66509bbf88e25ab8836f91150275db3fdd9ff5f634d5797b93f14a2f5ce1222bd031739aa079e1362f3787879a991721e196e3429d83874974d1f98602f8be2b250b93e39db780c2261996ab52c14621ce06f266caf38f1b6239d181e69d0cd5c0868d108394d3c40038a5b4462097220aaae4dbf8cecb68699b941d7691dd28fce5348bbaa120dadffa713cf09afe2d1a05eaf827d76e15a1f61f62f4ee07b0f5130cd3b147fe5e08f278c852e96f1e9d10c639207a9ed07043e1c99552edd58eaaed16d150e6553a4b74420ad05e01061af10336604608dd006a3dba60f9f6e4328097ab0ae2a163eede11cfb38a0cadc1ccb848eac425e360f8af98e9231ec8973a8c93075268e3d4e008cf50f28bacc2f8c31709350b9b20d2fc9edf924605c8b2ef58b7bf83e838f7ae9ac330bd7e8dfbe36dfb0401b3589ecbb7bdb4f96a726529a9bec47517b9c732691514168ff7ccc2e53dd1bd0a137ef295d35b2a5b94dcb8dfcf435ee5c9113dba46e36d236b8a8a50f40ab108aac06dd0776b20c2758eb52e06670af509e87d8232d990454de648441a2be111ffd8b530772eadf2a0109c1f768b42eb2a1f74311c3bbd4f5e4c2a6724c888c964020fd54a01ab3067ba70e96579990b7d5816e1462abcc548839bfe67134ee630f7e0cfb40873dc3537275ecaa76638c7274e4b8e0ee3d1be7cd9c559a75d61557512338bac819c40b363a01586516a90d85231cd704c77db7ff55d6853a7cbafe099a1dbb2e634aa61922775e03d7e98c9888c9f841272b250a0c60a7d915cd9ad76a7e0dd2922a6696b708415c6bd7724dae079adaa6ae8a9be480998cf460fffc92ed2287718d4945a32129845f5aeb5620e94cf139fdf0734e37b902620ade7c29f0c67a59fa884b59afa74f57daf87f22b2b5883c49fbaaf461a8604ced72b6f6d5f8d74afccbb4dac1ebeca56108babd943c04c451e2d9af8f012337d666d436d95a41d834a149ce671fb11da493b2260d71fc8ebe8eefcfb5022265c790418f57fa1d5456a8a8083f07b99b6613688be71726622917bf64d0fa3f772bcaf1324596d4ac49225a49473ab5ad855208d415d6ebb99a81e58a6ff5bc2cac068db8fa7f8b06c952b08b4f235b8284cf521a83fba8f55588e78703a6db40d39ae23f9e7e81bd578e4a817f318836af56729f192845c8004440d785123d3d6fa728cd5f190fd37c19006867959c970387b5f8450c9e591963a8210482659ff853ff63532a6e93ac073e6e11bf3cbdb555214d32260b4346ebc107528ad60930c68b3b1677f9eb5bf065112800be442bcac69452fda7de32090e707618e2877847ea968cb717ebebe5026f98cca510eb207fca2e4f73bf219dcab258171b481f300f294a2eb5717a677ab63646abd5f67b5ede0349a673210bcc6c956f2726dd26c25eb43db6defa94a9b4851717b0ff2d246ecb7bb5e7f8a4096d41cc3a192c1da9d4f8844fa846227eae5ff19319daddb3d75fb1a12520d5dba0373c1baee40fbb773b18a938578b943dce956fed772152ec96ec344bb76109cb8082c9a875dd15a490f75e1f7412bfd245f0ab527cbf14e2683c50a0c166366798389bcd0d2edf20d333611447e84e2fa0a0f20276f7986e906304a659ca7c2d0ec7293bdd7fe0d44808cc3c5e5f9000000000000000000000000000000000000000003101319212a2f37af420e4306750f849b94d8df8fae95dcd5bf3dddddecf25518d0af0bedf1788e846dfa724038fa2cd7f7482fa2a9245432b3b70750af1bd3ec570e238a518faecf4a82e35202e516d8514a199a4443b78c4e4b08e7043fe187390ba2872d290c169a0dd43bb7258d7e1c8a2ca391f39fbad47ea9516b05a8058fb1472b1eb7aa2fd53baf0db319f3e4aecd7e30c39d6e8b295cfe493e844575c34016caab6c00b6ecd9c8b91fde31909aa71a2cfd9e7567a06edc7d3f41cc9e82e1afed94179d6578b0ab32f2327ef87cbc7115467fa6d1acdab6f249b44bbe101b11efd8e092218b916d685cc50fddda9f34eb47291f2a232e8c947f4a96b5df3645a14a262c819aa2c4ad091995fad7c220a3f8bf8194163d55b11960f3d50182b2f90a0d871f611ee5e4f969bcd3a2899f9b6055191dd060831cc817ace70140a98e9e41ebd134117c0c5349837af3c2274062db2f153135ec33e29e2146ffa88a9684e13744dfbf521772a40bf96c55f02181c2fbceff6b80a566bcaa0ed1d7ed2a4c29e4dc136b027c8482ecca5bd59552815b4d118fb27214d2064f44f1dd72553b0053319e1fff2aa62e901b4d42afde979042d5b16baec4561d113fa4a445901fabd11ee409b80856e5fc7a7fa1c7c3b0336a88a8d707e6af43716b7592236eefd203ee4c7c427b3cb573d22e94153bfc27bb7720bfd351eb925c4e34002bfcae5bc06b746c735f62696e64696e67a26374627358fea96375736563746c73656c6162656c78184d4143554c412d50512d42494e44494e472d544c532d5631676e6f64655f696458208cf46f6d3eac72cfa2fcb212a42924ab05b4f7a43a8a69ab91add300f95699f9677369675f616c676f4d4c2d4453412d38372d505333383468686173685f616c67675348412d333834696e6f745f61667465721b000001a0acc226006a62696e64696e675f696450dc2ab2b5cef77e14e18aa2477ffc5b686a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f686173685830ba5d003ee50124a47aa0d55c045bb81cc4d8f48f8f2af53deb5f9814f14bd78ea652cab197fc2348547d4cde771bf49b697369676e6174757265591413038adb5a4058879c2f50243d285f1d2f4ecd7c5dc51455e503e31eee5f4a3faa497fdabcff9eadcae2200f4281668da308c5b72ffbfffd03fcbbfd1f326fff06c3978e101d23aee98687cb41ca97766bae744fa5ebac13a7ed62ceb05c90e2435fc6e1b87ac97fa6c3a69000a8afd6bdbafb016d9fb8377755637ddc7ebe335ea083e5e4b550bbc52a2ffe884c0d9ac95c94fd83e241206f6de8e4af2ffcc2916e0a6f0dffde938325beddc1048f9f808fa1edc4ca2d43b99961f98c8f3db9c3ff5e8547d4db429da3fb63f83dcfbdcffd2b19a306daa7d4310fbc41dcfb789839ce8e704715a21f28bdeca1e797e2c93e88f387913601e57111d538d6657a0379d7b31290adf6a938271644b77120f4650580883bec5206ea2473682d3c91680c3b3f29574f733291da567e372924633db844762e97d1df0245ba30fe359c2b50e1cc63035050c2b1de397e7ab0e5d298396dd7ed766a9aff049f6cd73cf71f12469896d7b170eb2704d80ad616a52ae2207b1e4a19b0bbad17cbd8e5d0d73e7770831c5396cd6d849cc7a55a0446bceab24e005bc46e6c21cf87d671b3c863e4a94772c723c394b4ffb6a90e608f27d69a19969e37b29ea0267077fb78cc35169dd094ae72ff91e94230bcc83edd503cce7d2b562923d847d6da3a8951384c2161c1ba0cecd9f86173316eefc9031d7fb9c1886a60dae591b1e7adff465538fdf73ad3f4856df219c6a65378f2f29089d637b200f4ea8f03e25766f952f4f5e77a7c679888b619475f62c06a5c5f97742d88fc77bfe70d7e626761d0178571012c8d01a3d9f1da054cee5f01287de21afa01f2b503ce49cc170c02c961f28a52403812443cfcafbd4c582fd78ef40b656fac0cdd57dd922bb0493903caddde8434ccc34cf25f3a68176bc966ded85bdab7bf81c6708bf8630832cb0105719161fdc69662a9432a6284ab6329807fc781803bcb697aa0bf6a67d58e4dc5fab740fd2c4de5ebd91edc1801689f6b609620aaf76d9fc212c4d0dec2bc2ccbecfbbe71e4352569608629b15603b4d8454da7503389e775746a25b14350fc3b2412553e21fd33779dc669ab07c57ac66d902f57d5a25ce715cd5e3a2f70906d16b13862df2ec8a73ab33b4e6316b3d3905cc041161c02a0ced85387684a5ceac5579cfc062cc1569e4dea31be1be0d3d510d4fb72b2a19259f9007533a56553fcb45212b1b25c6c16bfbe316a231d2fef7778bb7e5038bb6f763f73cb51c0fbb66f8dae720255c57eac02d6a4b1ac8fb611bef24b10e0d5ab916c8b8430f743f8e0a90e613fddc748bb1bca3333e27568101db28e2418914fdde36be70db16b61888ec481c358be7c0c1c20ae739348b7b04bfca6f1f106f6db1ea7ae95ec2ee22a782a5446bcedbc1c1e3a19842e0fedc06a70c1f9980114ddc33ad55bea486f51b0369f9e45ef6a654fc1af9cc873d998c94f90a5f7bb5c3b8f3b1d464d813d27ef701a68f0c2ee9d9da9d3ab923fcb4c92d894f720fc599cef3f3c07d09023cc6d4c8bad7a25ba07eb4d8ac0706785f4bbbfc6d2a3ca23303e562ced412bb260ebb095e76a8a005b330d51dc848e4a1fe48f898e17bcf38ddde8a35dd6efe7860a48ab19898fd5b655489acc60da756c85797980efaf667415ebc820cc96579e046c1cf0b61c84f888c8a6673929dce4ab9803bacefe596c67c1c8c063e0bc080504efbacc97a7d269a6d1ca5875843e92f56be092cf20e12ada3e4d7d72c6a0943d8a2ba8c6b08fd393abf36c0cf707be2247e45dc7daa1d64ba6162b75fb3310751476b9284abcaa3f316127411c6b9e80e3fc7bf4435377eb3725efb9d51d5386ec5330c775f4b4a7f6538ac7019862a4e0e3205794c3f9bc2e02c2dc453ba01861d9782a97187cc3263fb99a78e851770544e26a1276f20c1b633f450047021f62a8de853f3dac9aeec1c565116baf8c771e4c8a2376c7274d8c80c63da6c8b73ee123a26d97311d876ca62935ad0a8757170108be9d00d39d52bb798a72e89c92467c89ebee60864af99bed17d824f24dfe24d277feba2bc5b709d443ce816ae3d6b444075eb85a7b7f75570c2e21dff005bdc3d67ab1f6cccf4554fb21c93034538b3c62644e13309616746c10cb17f907690b57a4c3c98ccdbc78822c573ec663cb19981e04ef47cf9e66f84d3ee13f762eb11ab26c2f1025a95b12f365331f7a6432820d8318b55f42f896e39e1e5c4295ff5b33a4bc386a9ceff834baf3ab6c0b212f8d485f7df1a2ef5b1416caa2448d3dca7d6001492f3270292983c8550966751ecc8e7d730bbb904dba8b2707a8b0c4cbe174388216da38b30d476a2e5e61d7aedebf2cd087f41da00380fa0667dcbb0b24c970a5b80ef7e0a62fc3d18c6fce3ab479faca88b5f924f0ab84a7f3a554cf8e14d2a772628d8c6f5c8a3ffb3197849958b7b1e790a07e81fd183fa97fc16789ec6687853e5193a809d6b1979273e7c651c42c34325e8c568632be53eb8d69404abecf13df216b0b4fed0b3df8e13e7ef494c40700308c564970f6fb4f1b0bdc11b8c8e133e74b7043978d116b033c4f7280a911055e524224b247029693f8b27c975848c7438715f6a92510da3cc5a926c3c072d3070ae881c7b65a1fbe396ae7d5f632d20e816c38f4fb541ed2d995436ebb7f5331135ecf3497a36d17f1d441e4d34612248908f29cfd3dcc4ed1901d054c5850952bc4833c1750bfc3b68598e7dd5607e1b6b31fda8ba8cbfbad84bf8ac282c7ca09b9453979d8a3bbb972e354dee39a8e6b7ef12fcf6549964c26c765d48b63d9097862df74927d7358be16219f63cc6334f990d9a65a2930ec3382a8a0b7aaa8970d1071aa934a08e75bcb77003408fae41926fd4421e14f464c9f508c22eb42e42e3b394d4f61cc2883d72cfa0307e0f2b6f76d427a6fb01e134290c220eb6ff6d1594fb90f4169d4f2e78d646fbed3cabd590e1987f1071d6ae4f3fc0a24f9319cf298c00f7cca746e0884d85c4d243e56ca919772e5b955238de195fc9e19d2c2ff0011d580eb031bbe85a35b1c5ec0d0abd2062432c0b637a11da27cfb70cb91f302ae617cff4e0a5e38ba7e98176be2f06c1e11d6c36199b90b927f164fe61735f3b6bef8a973d70884002c2b5640ccd6018918f1de687071da74cb935d891b7534e0e00c042c9e72506999ad43b0caf16610b7d45de64808d4549d7a190cb2a5b91c5148f40eaed589c2ebeb821c5b22a2048fac143fa957703169db2a3d366364be22080d34d402fcad76bfefaad0c47b9128243a17cd68d1bf41362c3a2804545cfb9df7143103438ec7e8772933df7a551b844f21e414191f7896c29519e2246a18bd8e319ccc431466e52558948eed13d9b50c120cb58af1c246108481078004ccdf3a558f881904cbd2878f2e3ea631b82b47bb76d520eb472129d36439af5551db8af8390467fe1830b92a1a5e52bbaa8e571d0db13c93403d508b299ab7ae44f4b5a7576cd8dc11222db7717cd67744dc5a55bad713efb1c8f6ebfa88c0e9c5eed33d9c2f7b25bc4f64d00edcffac660adbcd82c5a55184a71b73203fafb871b494f661c5e677fedf89ad974689d88d0d1e474555e65c951d501e37910d30172f26f179f9c3bca222511b7fe0dc6a0ecc5ad89a72c47c8f50652e997c5b03e5e41593bbcf1fe19723d95a7ad0f75f12176ed4756328bb08d3e47247723bb9040d1184bba837a607968ed1aefd4f048425ed29e7ea6bc95d43df6ce36ed10019c51808d6355f239148562e3d66dff83dfef9490d9ab521d09a62fe7eb7f5476abf16c546c59d7f5d58f717058d8430239fa32d7d4a5b0ef3defd20db5513d0b4c0b81d6c40b0e935968ee7984cf94f19d6959bb591aedf5958956f20645a5952a684ad223b023019d7c54e349d4f72fbf57e500cd45afdde00a0fabcd40bb755aa64921b2494d8710bad72b9f845ad2c9ec131db8e9a76b58bab4d8dea73c227df964f5feb7d8318173a27aa0c2930a7d1674609649d005b4fddcecec3649970e4fa26ee892d36b273db532d765cdb513c7e1889d0b9ef18cf5902fc33fd6ae180db2d82503612259dc4c67bf1f53fbdd230630cfa9e693203947556ba72da2dd18745cf3c2f24b3ee9e76f28fe19bc4b4815092bc1fdc26f7fa9c3de6383269a0ecf3679721dc581016532c4a63d4824481045a85a5d52aa2156399d1408c03ee76bae035d0907881b3d3f1a1338be313d2907b60913d3fc4fbd4b7dfec600dbac1724959c43a48255edbf8720f88a60c5b9ae0fd58fa53e017339745e0318273c742be5f99e26480488ae134bdf6fefc34b5a7dac1ddc9540718abf4d615f670f4e598285636b3a0358c82ac7616222e24eeaca51996d3d2b2b779661c9e2aa219a260d718ad73930757ed1fc4a54df5249422df9c0441cc2c7c90f823cdacb01ee54a6b2717bd03061c3c4e2552658fdc9a8e24ccef2c5f6cfbd1c89a50c052da1f66c88e0b69bddca3638d8f745e5dd98cafee2436e873eb43787e86c21864b331c20cd7815f1d56e8fc12e1ac70c2ec60cc058306d633b94b8cc97dd6066e433fdc0a629112522408176a64d0d47f7fbda059edf458babafb0d1ac0fa995f6b679c261add9299cff8d720158c5d2199b2a84b1a18f2f12095b075924b20d0d818adfc695d21291bc36b880ffa310f8fb1368d9bb7a6af27497ed3de41128fcea13c24e894ad04c83b7b616a0efa8de82ca8ee021c41f0f3bfaefd04af24fb82119ab519e96f1221b0c0c52fb51ddc7a203ba71192a3b27cea905c5a6024fbd0afa358b4a23fbc2285fc3ac361c888edbe76876bcc0bc2da8c3fc7d3bb985ccd0d17c1748efb436cd1f18e1b8626cd848e9760532180b5087c89ccdc8fb3181d64b367d33fae9938f1c627779eadbdab6c5a4f630516777e35a9654d115589e4f16fee6a88265d87a51aed79945ad5b9c6c11017a01e2fc9c0623ddaf073ba6e3c455b7f509e81ae7db69f969dca33327c28009639ff11a9da06fa8c78d384b92285f7f9dd971353dcfee0ee0232aa0be01b1866e8102e6fa16eefbb3a202c31199006dc82acc91d0278a361063dfe0da0e8d2ec4990c0b87f7952e9b9a2892cc80ea48d279ea1407aad29661d7256260837cdce737796b093ec17e61e4baf45469b8f04d1186fbb8a9695207429d610dbe464b6378bf4b1f998fe9aa30eb4fba9f4083f5a1a1b0fd9b1609b65e51b94860eba34d50830a2884c9600e28305f4c57e9c38dd872d69ae31715bd1e5bdb10e835cf8058e1388a975de375ce32d717eaa649316e2c02e40aed8503ce901bb54216f75309a81305a9fb4b23274ae3cf0e90372a1db9ecfc9b097a1c591017d0b31154e830dfa031aa88bbffea5c02abce47283db103fc1796cbf16f6bbdf309bd7c8a3a280da243ed6058af8d270d60ec1e6488a59846da7f2fe0fa36409da3ee27b134da56831647b7270d85f0db0dbbad7a9db89543415f18a33757c5c04f07b1f190dd7e4cc1e7989557ebb75bc01cdc4add5c4c88b7fe08c3bf48b8d16c120ba2380af5017ebb63db96cdf2e89231d978372e9084c7ee9dc0b10dffe59716377153e44fa2984e3256e98864d166c1b76e47122bd9e2979114f819a8ea9a5f67ea254f2ba58115834bc7ab2c31c869156d2ecc5a7aa73a76ed62c93317ac4141cc7026acd66957cf28eaa384b6de44460cbc20205e7ad60de21b495cf9f1b3d6303059014916722799d09075742598ca1760d5c5fc31e846e5e194ee78dafad4d46323a0d6c676383129f6048d0fbd3a3b350cbb6bd693c75df3208e03c8e4ce48c421204f11a7bfa53bf99d69e310b7d35aba469657f3b00f9175a2aa12c1fd1e0b7119030470019b5dc6fea1d98b4e3ce70971fce2efac5e483257668842ac79167e5a6062f4d6ccaa3341e196a8b0c4b35ad6b639eeee20c35e3e8a05cfbc2ef2ca41ec86b6f3270e64f2a8f8ea1141239a1a4c04ad9966ea4d8593332abf6aa4a6c340b433dbe00a8d7a3d8392e25fa9710f209bf782418ad98886c1239a2b4fa9a32e68da9a99b1875b81183a70e049c8e2ea491b755b37805cbd566f9ae6e0d6cb31f73192f298184e349c2faae295ecdf3e90cbbf8e8615755cb02249d4ab3ae376b7ed9e1437c66c145ca8c547dd42ae0e563688d1d73a87727c0b0dbde3052d1364a33de97a63f42e9f995d3664417591a75eef2d5d3f4121147fdeaf1e925cbb0909d446501e20e2d7311ff02d1e67e9c492b7c1ecf2463f572e6ea5eb421d73474cbc0fed929dc037b8ea6a5b0165cf6395702bd9ffcd0355d98bc6b1133131f12b35ebe49ad3211cd087e804148ef0eef9ae77637d81deaf9ba82a367fb74854ee1d686fdb4c9df9c2ab5bff88ac4e80be7397174859e9fe8ec07090e3c5e7287a0a2f45465748fa3a80d11878a9cd9edef041f8f91afcedc11399eb8b91e73a7c0ecf235459db3fe000000000000000000000000000000000000000008121820272c3237a7a57862934d34f96af336852f4fa7a336885a141dbbca62552d6e15e1b2f38c81e326f305f719242e3b2bcd018eb43f888b457f4b88812a9b3d059026f6540f4ebd091fb291a7008324b7dce62442c1f0373ff539610ff4c8a85106e78a55f1af2a93b65be92335f8b723b2112dac8f4b390b72a41bf6fcbc696e98bf0145f19e1c4d527bd67563480637c3888bff8db8a7e9d0f730463623cf3eb4b06060c1fa198e564ae8751e569615e96a35c8acb5803b90e19d83b2f9448570cf05e5ac3e6a4f22ed055d0647e25bf8fe9000f9caff0609507646128124919074e2df0e211520d09d8001f6cb1a25e4f4893d6b0657d7ee8f1db8814a1c88f06e9353c2afdbe5a8a27b7e90fbddee60c69aaf273f994cf46cc492f56929b95c71ddd4feba92d31069e27a3ff7143d92ec75ed126692b6f152c987f70c182d69008830c76512406c015762730d18cad70f1034507fad2a48cf1bee185f827a93a825965c549370ba2584b26add0682013cca41a5329820416318b1dcd1a7f93315c6639edef5837a1c5ff6b6213912a701efb64b3e10338228a43584388ae637888efd29f358bc693631a24b14be291c2accf017a756e0976e06c483ad7da8f561ab65a18950109c67aee59fa9373dc4274cd168f0edf817bad80d6f0aceaefb99c3c9f7ead1319358c14fdcfe6c8686e06ab3a12ab1afc3ce285823120c41e6520aea2c6c6964656e746974795f6b6579590c2e5379af0a6a13e1fea75032810c1f7043f9917bcaf75332bf1e7d3b42e02247c4915a0d7b932010e9f8512f2c73c53b7a0f80965fa7fb7fa8743a85b5944520764a4aed6805d4a63265798675acd523c88895e93c6966269e8eae7259cd45185128e293ec09458b96f562fcb3fbefb7d2ab1ab68cfc94cc6bcdca1781bb6024ac6aa77c8734fabf6424f61e2cdd9e78261c16aca8b1842c8922a81af22bd11e3d8fe417f63f50a581acfdaa2fb22c263fdee4e6c682dcf2bc6b06df38d1d79f3cafdbc2b411d79f6d99f8a18f3f1f806c0cc222cbc181013a9967dc90cf3057504d3727c002d31bb31599faccba9fdd8905903db6a2f92c045e1ab682d5b5201d1a721349c5727b0361cf856f0e92137009bb0908eb828bfbf488ef6782f8024ecd66e5a1ee00d1be26fdbe4e99b022fac7b7e4dccdb35ee2f98ecf56d433c5bf8c8b3fc809e3f35cdc1db1bf7ebdd7e5318359a90f619e2b7f8ad72816a906d75878436b2afc049b66a82ffa451284fe6544925bdd5b634888a9be565344998e6ad94807baaeba6d185a624d272623c85901dba3bc2385d7917b303ece2e2ca4a1badf4c3f35a765f0ff32591a3133edd974bf7a5847997b5b31509f5a647ac12646d86fdad45bcb0b617c85e0baaa0e32dc9f12424c4ad2be40ddfa647c327995a560602b1e188210fc5e6d880c93f27e703c102ee2afe667ec4444dd50ef3375f6539cbdcc9d40c18dc8b77dd2227ebc8e1d3f4212afdbbac4f64430718761c7b81007de7c5f5a1b85ad7f0b2ff48ddc6a64b6cc75a54372d3fb473f2519af05ff83f6e46ab96d2cc52d224197ce42fa458cf574ea4d9a63310ae124bb07faa1362e5baf60dcf92acd8c6c23d54e93b72aebfc2cd918508542fc6668bbc0bc5527d9dfc80e9b23ea720e695f34b5038c80a6eac9f2d5f7db87affa72dbdc0f5e46203a167d3d40fdf94e2358b0d62525fcb0f87b746a42f939d71614cc3cdd1f6c50f02ca88c44dbad5b12185808b79c5c468e82735fa445f35a4329297ede61af2f5f004921efd02eddf504e137300f0ff9456319c1b4b013c3c5bad987f18d7faa5063db5c983a57b6e7fb7b8c03cd5c898e70070f1d4d4a605e54f3305253388577a4c36fe9f1017a28ad03ae354810f912aae15d0edd10bcac5454a47f5c3db442dd25255b28d1fee87efc7eefc7931881be20d886c769154554044ed13dc1283759a8232c52fb13d8a73ee159b957d26c846e34b8febcb75a0b9b5c5d0bb8586d7b1969c4ce5ef75cd312429f78c1225999c6089308dc0bd7a5ff3f3424c92637b3254b362dc32a4fbd6b65591c42f175d88ccc0fbbe385591d775b5f0dec7d782d221573aedba85a561ba0beef343f615e933533bc459ccc369bde60fb17387d118230902365d9ed97608aa983b4935df3794b8105292085098ba57b0022a0b2b403e500172da98732ba3b9d3e878439fee1162febb86f6338e336360f3b34c8fd019a5805ecbf898278169b79214b509a504d1e887711a037ce69a3288e5aa9b62a1d52c88eedced6baecc3d9ec5a1bd91369c95f16eea14cc20e62fa5addcfc859d553e1e074f1d435de5d9cdb723dcf12314f2f254f0ad8b5900e9cd652756a5328e47a12ddb564eae737101780d26438bbcc4f68232e98f74f5413d9f80b46d2212e4b85129a4da1c8866b1ce69fb9a64565d49febae70789b368cb8b3935deafc11b4dd2bf16438de5c8a9492654c53f4a7bfb613de0d118dd90d6ccae1c8fd51ce56eabbb58cd85428a8f408ee1c69c45eea37a6feb6ee9ea34e82df491df0a54b87b20821591e264396ae5e1e3d862116ebae9f682f9dfaf09e18b9d1eacf26d8057fce2cf9f5675837f216bfc5816da1586adc0199f8bfb9e06cc9efdad8acf0b397574dc9970d6acac02d8618e8390c07b520d571e6953dd2090c6552ca19e83bee860ab37e0ccc62041eec86fe5a2e5e7ec7ce02a42924bb3d6eab79c9e241dd9e86b1b7d2a52c2eb6fd47bb8a72f099b80fe6406a5a4b181c29bde1a72d5fcb9b3fb064cf744d248d1f651064e55ff089e0c256c4cdb3aea6188c0301a980ad6daed2f9fb6d7056e9def7d9c5cf7c7096d0b85f571b77bf0b366d0244a6af06e7efce8274ad56b55ec45ffaf4b60a1c40b09ba86a315afa216aa26c859cf3ee622dad3fda386184849a33af084bae3bf965740eff17b12bbbc069bbf764bbf59fe0640d2c838f09bcb36101d7feb5f5a57625c363223ab2a38ba06890b0a39fc5199445e5aac7b802838fcd869536a8051aebd7b42198d5ee9269823b5c73aa3a548d0fb034e5e0bcb18d5ed91353628df30a49d6665ef986d5e6db8788311bc1bb83296329fe1ed1f52b390d56da33787c1d6a30adbcf0392405aa016f7b6dcdfc19a2561f8d8d7fcdacac1334d967641cf8aa07bbfe094c839550a7e4f99249a44bc8dd50a3d4d64afed5fd3315f360604fd7c5964a37e21d8d6c0fea42c81dc36116481f6f21aa99376048efdf5abb892de421690d1024a69b511352dd96f1fa9ef31db6dcc551063e058f958e416fa0b72498296712b02326a6686584d7799accd9c90134c340ed0c9467efbf2ad404cbf1fd78e399e8bc3619e21ea69e649886054a1e39c265a5f151c5cb7596c0d376de8c45757f979e11b3e0872a5d9fa7836e06f9b500fba674be5f401ea54372acc162399d545e0e2871f433d8e75442a327fd005b486d05acb474abb8fcc2ebd297ca9a226cd53c25315bb39ee9b8dab61f75b71cf2793c37ba46f65e8fa73c997704b9c8f30344071eee7d34c3d05bf7f6443632c44b419f96f2f144403b6fa5bdde0aaa107d4315a6cd2dff696cbb4a7bb7593411ba10689c784251192ae830f34e8dc3731283f1956f4551ee3ec0bd18b6995309ffa0bd116e10d0eb6dc1c321df4f3c22923d43bae31673e2bc64ae539a7e073062e0e0bf9a7b5f4e8f7d68453e7b907a2369623ad540fbbd09ceb2a814f6415400531897b60d19c6f96bb95dc34cc536c932c718fd7b3c30957168cee019153626a6f92186064664300d5652edd3ee00a6d414498dc5301c7b2754a896e1166e1496e2d8d58b97563f43cba6558a72557916f3c71e468162f2f33f964fb8e2da1bf0051ea30e93ba1d7e5cd200be21b78e2d4a7ca56f400a6ca5ba7c53b330ea713ea97f222cbbb3a8010686c5940198a962d710953961809d59f409a59095b2178b341714dd0ea80498a2eee898fd01d0dc1d8936366996334882188878e013b7bfeb85e12eeeb4699b5ea38fb3e58fe231c479692a8860bd5b669ec466bfe74f066d51130448190c589cf85962512dde3598b31a0c839d31b61c67368b034f1dcf2099d4d536898a03a0b22375594a81a871ebfc7c23dda0a3cd32861817a7745b68894460bd541c6f8867f1cc0cf6f54869b9e624c2ed5b17a7da6826d8a243b50eb843e65095e1451929d21cd4bd98e51c5e5d81644727e2fef9158c539354bf4cc518112c7c2ceb2e71031a927a6022b484e0a9d41bf572fb5279ef4ec3bf5bb881ddd0b319290ad7c9088de42a01e40d48d286607a6fedf1beadac3e3b39a1b01bbff5a319862087c72f7b353b9e4e0f5334dbd7e5c467f10e8382388f4ca14feac44afa4a87ca32d54719bc3082020a0282020100cfe71a6226d55705d4904a9cdce0f030b9283a067064f577c106252f171e2f0258c79fa0158bfde971b2c1b30c233c18736dc6a308173adb242281fb103048720904d9c163ab861da043ac2baf4b4edae33f4b341be51da0da64cdddc8cb624f8347618a4601772496f70d798f38592af31291c553fdd0d13539aa9d17544fd4797582756fa60404bbb306e5170d0276bab1ba35af7ebbbcf83623752991920179de99e9b824a4b39d518a283d870f2091ea2bb69d62c6be790a1a2224a96342a113c0bdea0d7aaccfb2c1ab18566a7a5eb5abbc1231a29d8adedc1b27859493a17c21239c1254f088aaaad1b6c0baabd4f9c567b0bffeceb7dbdc0c9b5beefb28faf4619a7f81df21122229be00e9372aa3afceaa719bda7557e294dcd2cd42c0feb31cf9135882374b396edf34af1f004e9fdf2084d828ba51fd5a4346861622e044fa9c237ada92c3dae3442a4007b7672262d8c64c10e1b7436b17df980ca12f266a02fba83ecea5a573a2c0b6883e2bbf2e29ab4ee0fa9c00a5678ea4f93dff518c5abd0036ce26b5617e7ba3b8e12f0f381f3332b0fdfe9dc34cd904313a2e7c97a0806b996876f84fd71b520429bb33ce2cc5b517031babe1d004048ccecace93e9a47c06e1928c568b02fa0a220ca706841752e910725c758000ec6242ed677ae8ccd8ee76ef68cc9c081e3d317890cfb375c5896c1d5aa963ebea090203010001", + "profile": "pq_hybrid" + } + ], + "generator": "macula_handshake at macula v12.1.0, OTP 28", + "leaf": "61206c6561662063657274696669636174652c20617320697473206c697374656e65722070726573656e7473206974", + "now": 1789000000000 +} \ No newline at end of file diff --git a/tests/vectors/identity/erlang_bindings.json b/tests/vectors/identity/erlang_bindings.json new file mode 100644 index 0000000..1a0101b --- /dev/null +++ b/tests/vectors/identity/erlang_bindings.json @@ -0,0 +1,54 @@ +{ + "entries": [ + { + "connect_binding": { + "signature": "8e3302de5e6b923517bd935edac157095db9ce3f08e489d1f7b5d237604fa68a9f883c92f307789112b1511c9147361765bc3d8dcc78677a37d06f41d8bd9711d0ac3f6b56d78de3b66798b0aab8d95cb25624ecbe9e8cdf3777f39f70c27f2648227d8b54936edf41ebfbf4fc26c49ac13ee86825376e1d2a7b808b4e62c4c8e3625770c69175b0e92f21a2e3a5d07e646556326f2211f3ab963fda69d2b2e6f488ca2e179e598d6180bf63f5ecd131243791e1ab5f0b6f228e41eb9b3c0d6b3436a34dfdcdd2b6cdfd39e41203de18d55927ddb16c4775b4c3f348f13bde165f2368729a14c568a7c3d491f4d1ed4866d5ded69b7c17de3d04774289f40094d71cd3294b13930c063e28c014b8e2fd72cec7e61df2e2fb74965b5057a79606521243cbc5f77ba73e790e65146cceed9dc7f460258cc19ee87b4ed19881bbed68d761b08bd410e0ac5a9df5a6bee1ceb00b577d0f14025ba936be662cb721525640f90e28754f9e48d81e4c5838a0e857822c3a4326aae972089796a07cd027b7548e61a3243d86ba2c074e5192496bc0f81392a5d0a1d8a2bd28fd37b3fc0dc17069205a35cdd5d938df824fc474cdcb51258e5a0d93d2ef690a222de4b13628bf25a74e1efdccd9f4265bda02d46c34fb23097a2378259d8ee44a9088ca4b74efc14a7d29a4ea73995f22514ec1f8f55083815b9461770edebb5e57d26faa2f1d62e9116549d5aa550ca7d80ba454969fdd2158712bf06d7f822f8adc0ed9827a46708f05a01a185ad70eb29f3b8436437ac200498290b05c41b0aa4ed1854867a6529842f513375ade7f13ec2995a4c4dc3cde320f2d46bae447d9fe2c6c13716dbb8c148ece832fa057e98610b018f42166076018568cdec7704a445e125257f90a593ae63fc63f4883c8da626ce807bee1e31d63944c0f5d7b880b186582e36083cc7b20cd5e82b51b8c5fa32ef0f453da3ddf08d4eb1cf306c5963ae2201413e60be61453d7f2882c17960d2b655be85015f15dff11cedb7b0c6da921efd4e87a9090cc5455f2d4f7dd4af41b5b41457ad75b2388d8de70f8ea8fc803eba25a827792c6dac769ed9aa8ca683f7288897444856f2f15965da0d31dd1d6dc1b24bc56113cd3368f105a55a437c5322812ab7df99a321ecf71c1c5a4606124b03d51b998a10f9223cd67a2923b56ac3b2fa23ba45904eb05d5cb92cf21bdf084ce81ac3f45297d63a14648c5cbfb44d685878dca9a522cfdce5ce3c3c0909d871862c105c50fda7795cb34503d67cc2068edece1b0ed0e8bea2732c053e58ef81afc9fc2c9d76266d11281b4e4b83fba3ec2a707992d7a04b02d3f4c04e92674f0ecf1ca6aadfbfe357437a6ad6c8bd30907f593a698defba86e914dd4487e74072d3fbc4d6c9d1a93c34fb9242170d72e6a99a8a9e03117c6786b545824714656e0db19d93586a1b9d678c5d08800b606363ddc1003341fb87d5de5560eeaa96ddece51f0ff786dac932d8548e55a531ac08e609b365f0293f99201791c4b2e53f11803b2beb2d191566dc2d9844cb331e761ffd0ec2bb258f85453ebf046626ebf03dae49f6e5df8ea212c7f321a29f7d5c1a0b455391c52f988af2c111812624ae760a4dbe92d7c84e193ab876496e0d606921605685b601e59e2e9861980bce2a5d7c378dfb61bda6129a030d954fa28539725859c2e45ffbfe974190cb50cf8dc637840bc33cad225b8463631f3e32b33955d50c5b032c39a489fc91380ed02716ac212c5ab8e94205e6ccd9f280b01d19d7638de1f3110f0b96f6ffe39310166540dc8f8114049da5750af4a12bd3f3a88d4e54896f104cbede05a472510a6097b35e79a3d66c3e550fff4940ad25aa927642d8280797973e97c8c52ba8d39a4849b5bbdbd73c7dcc5ce01cda56c122abacc4969d41d952632a3477c8316d87c57257b394dcd9495c54243d430b48c590e4ab996caf0476fc417961e6ca65b25131595bb7ba83d1818a37fb3781e2ac01f3e7b654939d881cd652f3f526cca8326472597803af73685630ef0cadc9f7621609104d7db8c5c26aa9c12331f11c3b3bed5aa18e81228b64139f8876012e631417c0a9f61a95b715a8f6e1b83f1544de1ff6801433c584013dd618b82884f9e9d3e6e4ce97e9f20735e5abfa67109fed30786e36a0d41c0d2de451d4077af3bdcc1b84d2bad2012cf03080ab29d8ba16adb93b31be2821648351f2a551075dc9316dbc5cc2b722e71cdb793ecde541e0b0cf3003d9ea347fd7b68b0ce5bfb1a77928a8126046845f482f879836c926fc64b5b7165ea8a17b56226668193c8a8172f474026db3eec97810615f864a2c97d1a725d3aa075b11213c3110bdd3a2898dd02dc044223cb07c72a583cf0063d7875e47b0f935bbb42b3683c1f699d826a92fff2f2bd984518721d6ecf42924e09c5150b3e98ea3a3c791c023a61d4bc3b172cf4c9d4906d0d6455f4009bc449b8a8e5e5a50b005d0e218a146c1862ebb6611373774a6d90195c033bd15133bc891dba9fbbaa416ee7c009832533a8711a71dd17bbe5cdc5211787385e639d8d3ee9494f1e68c8bae535f1bf3541bec7d13290556ad96e7589a5bbb90b649d7a43f1011949735682aeae52f073f303817319d34ac63329c339d1dfa28cda1a8814ecf741b8ad19e04ee7153001b8f2ea688e352a33e45f80bb4cacf7b46b1dfbc308ef8d1fa7aef667dc3076046bafbca174c4ee9fc7664bcf14abd6f4b463a331eecbab297b42c1ea294aa6c0b88c08806bb8e5d318586baf090d7ac1d29f7be73d75d2e89cb210e066d952e71635576c00d513e7821bc894beafffa292cce05b2f900a6802a6dc7e620deddd78fb6e7ac67382accff516afd4e03e5ad7a99b87fe35af91a8540e76ec7210761d777ffeeb15d0e1f09b7a038778c1d134b871401be667e45588ea3b102cabc1dc7e6a83ceca7e43de67eb8bebbb1ec193f646d495620dab7d1564b7e12069e4225ee16f79bad8bf8ae2b2f0407a89c79f8c7ec49bb5991e82258004a9a19aff8d70458e4885bb27b56ebdc50e595d76096738db3f4a9f63f03627c3a4ea7dbf15c945dc9039210b7c7f3ae762f48917a7c4c96dbf588aee222093dd6ed6f294a14175a08591d1a676c42fc012389bc4a60e7050f67bc588ab3d480a876429d6a9fb5c5f7465b62fb4d38fdcce7433b18ebe1ebdc3036dde2fbf00dcf0855256d2eb8ea04dcf3bd16fbeee66e784a593eff4ecd75e2f801badf820b53c6899a2a21d5e0d8ceab7dd1c8a50ba96206f1e2415a8a11a0e0071dd3afa81ea923bef16712c2404db710b362088b279f35b41a7f021824bbe734eebff3001389d1db152e56802da08629b1fd028491982fee357552c2e0a8be9662867cae66928889774d0d89ce3bed45c7f8cd27de078e88c91e52e06bcb96685cd9ef0378a35490c8b7716d0ea1a5baf0bf997595a13ae21a4cc6fc48a0181ff74e063843fb9bcae5a8349e17e0723c71f5895426d1c3571c0edb40c80093c5a613b9e3cd1299a75d7d3231e515eb86e0ee5a83b20ade45b0cfb03ddd3e6b9e7403066975686e5d65696cb20df0b386dba8adc48b1d6e9566fc3650b8f673a52d68a675c90c575f58ee35575b6f7062dd185dc9fa706a1827b92435b90bb10bd5de399b758127bdf15c27e9a5cc799f5d98a28547117ff7fc1445218ef3232d22735d1c9b117a7c65c4b961642c1c80696180b3d5d039694fbcca14db70a667a6b32f636a1ebb2fa776912fc4efd76aeb9fb183a0994ff12a9ab7fbfef94d02bd256e65ebdca7f3eb4414f5e9dd6667f6269ba7d3dd0869627db1f933475982e0d6e55a404209857d7b98a2479ec8d2c4ebf6ebccb323866b254af2301e4b196d89085cd3e6e85e706c70d208445cb461aabc7f05da3b7c17624c66a63ffc89eeeaed55b54d5c655ca2003890bbbc1304a58bd01856a7365ca76914bed097ac8a9c961eb5163ea739354f075de9fcdb4d6504f1e0509e332ad943319e3207a8f002c7843432d55fbab6372e06c9d9cc0e90af55ecff69a706862d8cac47a3fe7f899b5ee6db335d3801241a1833e7eaac212739eeddf1d75546dcd562bfa26ceeab9fbd9704d1923f031a44dd18d50c9f221f190f6e51b7d9c0d5b8e5efc03cacc2597c489ed5ac01c372323d404ea0d5d2c56725892ff3af7a0249b3a51bdcc4f63504a2f7f0562ae61b2af47e96414e4b5cead868163e62839d3d94516022339d6c29a601ef7fbf66e0452a53db9c20511705711fb9a23e8fcf39be77eb5461d721a35de5d69d63990a3ba592134ee8171e2d4867bb1527bd018408b069bcb382a7a63e98d1eeb8e85a4bc0eded040ee09deb0a581f1e887ed0ea1a5473757a22944a4df30a4b4ab72689a93878cdfe9cd6e44522b8f14b2293603e8d651389fb2681ead876f189336a25b23ea5ee2c5968b99bc586d3f928bfd6f2873dc7c64bd75a123a2a113f36e2cdf590bbbb99381d0455b3086b03ca192556cf919a15ac334f0a92e779f75b96e7ef7bf1ddcfa1367de470267555c4f57324a3ae02ec4baa271428d4e84ad67aa841464da2ba57771777e4a83327df2924fe381d9b104953f069c0227ab59b4429095ee63ef9adae8d06754ee51304c98ddf5050ae6e609486065bdfeb3ca264db3c4c2e1dc7d38213b3dad168ebe44b5d76337c9f421d90e100c1f34e02426dd609abc6db72412d4601e44c2b44c4f75e97f271e53015ccbb02b1eb777cf14def7b43bbd7ea67d2dae9c042f70e949c79059c7f2a7b0e1d8ca694c985c7bffb3ffbcc20585e11513ca3b528b9a69b77086927f3212309e2edc3fc12185b78cb4cd99adf0adaa4bd33158e8415750f8713a214f51ee3030757c58579180af8ca1d8adc994183d9659ed3eceaa0aa3e9d92b95f17a27817ba76620adba307fa67f87beca15d5694878187b7a57e3bf2b9a6d46cb6f45d93697f56743ab0f53f387459b1453327d2b97b535e79e8c5ccec9de289f2c9f32c39d593e6a4faf62de9d544aa3f67c572bd3e1de156c7ffce9b672c40b324154f0c238332681348782ca7c7fb025c2e2e61165b8c5489148e21326c33e878c99f6408f57a00c774cda94096156d88ec8ac98fe33d090273565d97860531ffd7abc629d135ba131f8661573be85c6f6b7333a72d002fe3cf7af6d52d91c6cfb05d98a4b3f2151f376d042d61e63dbad617acc0cb1cafedc10258dfc5c53e2d20d49b6ba5579af8a13ddfff6b91604078aeedf0402315620c66fb3999b2519ae435145a61675b98257fef645af7f1d0986a13038c52701a4088f61cdfe166bc65edfd821438fb058e94d3e1458ad445a9cb0702acb0d1d3c71d211583ee2ca45865bb309547c7616e879c1e2d58695eec23275e5cb4f2f7a24e73054dd4a9aa868cc924185e82874cbe820964607143462c00bda3a312f6f1ef3a2ea53869b0e2ef1b6859bbff66dba7148e763b1be06a12110974b8f9a5012439ddaca7cccd3d2988481facc82450624228a64d4b765a7f96283e89652aa28ee73bc43a94a60de2edc574920a047284b93a82edcdc227f03cf6edc61f1b53f277453bdb9e81e76a80175c1cd259a8c7d6f03562f68c2b963447040bf5ee60a0604526f98bd415cf60a72302cc120f226121e94d0edaafe0032218f339c8d5dee18a33fde453f3072d6e6af37bdadd0d2134f9dde2e445900070cecf54411e38d49a825986a70f90a775c7dccc2b51ad05189f14c3e2efb2e4bce7e3f3009c35734ba611c11ec9127052d6475a5cabd64a3a2560901b349a409f93d4ffdf5bb475f9f104c0f68a1ef7e3fc882d5ecf521669754b4a55995db5d43304f44bea4d399d0c9ba76c5782320bb85d078c04cdd7665f61f49dd903ea4ddfa4433105a0f87c28a2f8511b792b4fad486d887daf1af918f6ef9133eb995c49b395389b0a7bb8daa1ea25e9f1047324f8c099802e50dbe5ae6a62a51917bb46dbe05b3e925acd2cebb3f27ea31094af3cfb17b264f917e8fa39589be14c6de7cfbf72e277a15f352f6ec17e37e5a15d1bf8e8e5ef0c3e3bc3a4d4d630744d96a6289cb628a86ec31936e4503f4d83bb62554c346238531d39b0d238edce50529723b5b4fd8d016b2adb9da3d4ab0032efa2c42339f4a7ccb00a9b9b5fc81422e8b15e88de6050ac0460e0a529477db00e8054aba966e8d09b53aec1b73c8106bd5d9825b7b49a27a6d739875ee64fa75aa348aa41263bdfb2a4ce4f098cfd895df5d0be66d7b7755894466db27cd7fe32a8f7a7ba18186a79731275c938ecada3e2a47bb07885c1989624868389affd947a115da819215fb901bbb85b66a105d2d9d8c0245ea40d4c3255b5c33ce6b82f9bc80fc8b69cc939c300518c1b652752e3f737981b5c9cb23282a84b8f8bfc905456c8aa4c5d7dadd195079869294a2adc8d8dc42889fcedd16175a6d92d2203b48535e698a98afc2000000000000000000000000000000000000080e101924292f39", + "tbs": "a96375736567636f6e6e656374656c6162656c781c4d4143554c412d50512d42494e44494e472d434f4e4e4543542d5631676e6f64655f69645820453222b1efc1868d63e35c005b444fae9fbadfd24ee2bd54cc2ff5a58e6197b1677369675f616c67694d4c2d4453412d383768686173685f616c67675348412d333834696e6f745f61667465721b000001a0acc226006a62696e64696e675f69645076568265b05e833374965197a96bbb976a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f686173685830bdb5c502f5d147943dbb7dda272f9ecaec4751115e55fba29a20c17662def3b520096bb80e11f4cde713113490553133" + }, + "connect_key": "ddf84bd7e30566b55c95cf9ba23427f2f7ead3cea462e89a5d2cef7af0e1331075a513617990708a6101fc0a53bd92f44c9667fcb6d4188052880bcefaac3591c1672d1a7380c8f466352ac044d84ca2c64b859897a143c8c6b18fd4968c52f7e9de8bb75d14b144fb05cbcbcbbe1d4735eee0f2c0808e1e0bf2bc8f7890fab17a1dfd96aca344b2d5ccacef3677da2fbe0185c4e8d8031de4c5a7b760a71f95a6816e6888fcf66f2c6f36fb3131a1c7cefbcb49426d39bcde5dd148f721722974b47bbc5ca5bf153756378ce55ce18ea9b5f451acc17c2f090cc42ded5b9312a6eb49afef4ac7510cf1c0a7db6ad7a6b3000e73d3360b2aa98c95e94689de05c04aa47229d5cb32e6cea8b8630f1439fda72b9918fdb7b9c0e610eed9dc7a46a84440af1154c5d2bb09d9f322882328d32a290f90ca269f6d4dc83e500fef799a836394d9b5268c73a5c097987d35e8460b77ee660a2ff82e6be372b3f9950ada976c65d989bb343adc43ac0d7544fc2a4e07579ec58eb2aed321ebdb142e9c14850093ee00a5c0413f4700d6e68459cb3f53e775e5a91fa2dba46ed2452bcacda47f1668d8205575a8a4e5c586027e24a43d68c7fa51ef5717b9d1bbe7f8183db455574945dbdd9eb1602a9598bec20737bdc14946e9d0ce9777ba1f9a3395e7ec00d7ae7562f44c18323e7740ebbe2f76ea6e615eb0077597ac538d47a67c274ed2261ae1ee84525f4ef556fd6349d5db6a9e08ef295939f658e65dfd4d8c3088c305cc93d48e49395c220c82b1a89c8c1945e5addb6ae6e2514f6a8264beebb77cc50db59efbacb40afccf65d43b74a14734acd09128abc4fbad95dbc26ff6a68aef1b8ab8275682dc51d0fb0d158f34e2f75e649cab0302692d61b3478b353b6c3d514acafa201d06bf3265762815319481bf8783bbe1b3806cab3eb5863429f220348a6dab46276c3e91c2839716abba0c9e61ab67c7618e54bcd8a5665a14a0575d228ee1990264044ff28a8ded5910a22a37012bec32913ec0a1618d9c871616c114fad1367d4ed9de51c78cdfff522284516c642fd6bb20d45419deb6536479593b8587406316683e4704292712f1cdddb3553025f04b0b6b3d8f112c7c646343c89332cd5b21c0c94aea33aeacb222dbcfc720a82ba1f519ac952eba7a1c1f4af4611e57af7cef9ae2077b76639cb3af007882e8a2150ffc757d87881be4f6d33d1dd47afb47a78d3c9181452de5df133572ca6b4ce4780359a1565458ab3ec307b65ef6be22cc92e3c873e93f5f5e68afdfd4ada7a48312264ea922071ec8b4e21d12425d4f299d2d8a6ebd501c0a81f00e893cd6d16de7cad7695033e15508765b53430f22221e7c75844bb30cf4355ba33d9cae9887e9dacc637203013e51f7da191b464aca3d870534c3c61b8d85bf574afc8268a04240fd4ec0571a36cfe763b7f6894b367576605348d0b179eac19dc5f4cd72ddce8be29872b09f05bde651a625c03b03c2cf84cffb894404ff82ec49a31868d62695ee488505b36234c5d81df318d79a543430a1544ab3c09bdcffd3db40b5a352781165dab2b6c8968bad2ae66b7e7b077bbaae08ab7538d2c46b79016c2d103e36e4bf6bcbfa0b7057fa4ba8c01ea5c24b12204a28639ea91eb4241e31d5c0fc0c025d648f7eef1432a275590c2aa6c72b6c878c3d79b4cf9bb3f5026c5f1dd1dfbcba4cd3c138b2b0e8f88f12e71395d8e5c9755325145c25e30f12cb0c181b0644430105fcdbc0c040b8d4c8ea0f5bc79feeb4090bb94d50e0d61e6ab238e14a1d2abdc750145531e72c08431852b71e8700cb81faf13a8a6dbc61591a02c7c8614cee51e32f920c766a7ec5bbd8f277b51222f82ba3839ad1a53f087258f5c5e881cbb0cb82cc8ab4eb4ae72db92962246cf3a465d2327f52f49dfc2717ec83304618d8ba1a3b55dcd38e19211f3492fc374954b29bfbf73f2042407fe8ec0e5bab4a002d1654da63db28326cb1066d2184a19750ce7011bbeb44ac013fc8e1332dbb6e25684ef0e9c59e507a45db6f1dbf2137bf7f80a26cc5a70d8684a7b38a31be349cbc8e15173573902f00edba6b58ff1ef90e759b856dba26e89bea00ba82fa6a1e008ed5078c44aba39620453b68f9c6fb6a0734793d9d18265cb68e9e90fc297c5d5d05440b93ad474f0cb7f8076b36fc062ac532d79f34e889df0ff5e63984ef72bdb58b2fc094d9993cff5d7a1adf8c5f297392c0c776bf0921412306749b669d77d34d5269460fd4a4e4dbe148bbd08c01ffac16b63a08bcbee4653aba376f0e084feeb7f323d44d1cc35e4261e0f18065f6666acc5c991a7a26d10519141dff42e224870ca38f45b926c080c5fdf4ef118f1637543090926d26596490eea15e91e2583506df876a724c89a5398f0098ce19b85d8ebde424d9147f505efa89386777ad7c03b1dc701bf7e20da6df50207ce7997e99f15972f75d0e3041005f4aa0fd4bc2d5dc216200897e38bed251da21dad496ddaf8af6ba4c4f6e6a3e6314a92f616df5d4243739cd3fd2c356dcca8bdd22a5fd0147c5ec1b76dd376f1c3f8d6ff7937a563dbf2247e3ca9576d18771ed0b9a0adc152ecf7b5ca7b742b7404dca32db14c1af7a11f07a59f61f74c46f42c70904a41998e9cecd7c057ea2bda9847546aa95ea541f60ecc988752a38615fef1ef7871f952f69416cc44d6c8e6e5be47461da1cb4713f86bdd666a4e42a73ad7e72f17a1a3a89d7d9ad05642c5356528843b2f071dbcc7e992d40c796dbe3a74d60bb7aa8ba5e0a5daa1826aa998385babdd7990c8f4bdcc4c53b8a2dd2d9482bc43922ce3e036fb02cc8c4ce76c6cd806db429557ac666bf24b92bfe55e2c5b688fd92cb6008791ef5a3ff90f68ee8ec83db411e15c72376186ba542e71e98f4bff4ac8f477fa04ec0e7d8d93a024220730cf8db268cfc7d6e083fe465af0d0695b475a98cc1df0a22d3fe5babfedd5d038353242701bcce7c1b2eaa11ac758b7614d896a41151f8023d95dec1b1b7d2f1888fe5a04bfb7b10579dd594a2521bba5cc9aded387ed3301f4fc10c585c0c8f9346c19db5000611cb237792bbab17494be949a7520747a70969d50209326bdfaf861e8d2fa99ab031af019c6f26d6e64a7a4287055e2281574c2ed3e3d4c0f6dc89c7eb4298856c35882c2cdf3e8aafa73df50f173b3542665267410e9c595b1abb7ac5c5b350b601e5ebacd7bba21a5538c6be3ae1d484330e6750e1d4a7d69df4768df127580acd881659155f2a193047c0e5543e3b214227419417ccf9b47149212a387d59d4271c1899fd93f46ab2e6f1d2f54cabef9eab561af5ad914c2f6b1406c9d12b239e4dc964c54a748519e3fcf6eaebf735e6ad7a8608319a73a6492c37f9bfd1aeecf1dd9d4bdfaba34fc0a9e61eaea2aff4c3d84a8e81c585aa22390262ad07a0de9cf954da68fba47a1aaa97c1e318b504eb600af65976f52bed30cc6d67e134c9a4af72e379b1c4bc04d3978231c620bf4a4450c98ab4a16ac3c59b1b5d35c74ea28198335f18a32fd3aaa8025b94bda3cf2760e84bab7f37d5599b3ce0fcbe5114b85ff4328563fc7b4e37ba053d8225cb932e43ac42aca9b12fd40592784da468704944dda9e9384e027d93d0c8", + "connect_status": { + "signature": "18a150c3abc11c1d9ed76294720ee2e4f5aca7dc22b3eb9a80c02a0a5e05aa44990b03326f7b3f9e336127877763ca7401feff0d5d64118d4744658deea3ac5077a0f9d068c7016edbabd2036caefb1c643ead283abd3a44d1e234f2e9d83f5e498dd9b4dd66e05b30d87ca37b0b93c8a5d948d727bdd40da50232907d3bee0280a948ecddff206582c10120399243f56eefd04b905ff0553e4acc770e2cf4631f69d66b6c957504de9824a3cee36dbca1b17720659d8565f56ba2bee0a9d0e495450edb168bf257e40e99690c13e39d44badf4bbce25e04374f51d0c34c5ebf57c16e3a1dd8a2646b3c71b4baf6b0a204ee45c1688d6bbb270c3ce79bb59ab40e7920eb0463c7e5b790978484c25a4446b71607914359a03fb762aaa6fcbd8aa2a75982021d08088af033fd3bc8bfd2ccdd246bd9f0f289467745d9698e26a9d82096761f0fb3c27329c2b621faf54924da9fd7bf14db211ae9c6921f9e2300a8b5f6eeb42a0dabe785d62f22d26f7256b665b75a3d6874a2941991894c9db83ece3f02eebd6e1544bc26b0ce33fa8c95ff18630503d901f8e5a2df86171de8d338a0d6ae32110b2b0d376ef034117fbd72c6683945389004cd508c0a81696b37e656937f04dee99a6939f2ca9e4bf8a8284839c2df71d63fb6a36af7f343d0784f43a0925e376ae90dd1d936a39bb737abaed6e7f9d5b8bbc291c9afb27f8e1e566f13a1a606a7acd6c47364d27e52e15dc98527186b993e78bcd434f41cf5ce0ed004c568f0b3271e5bddd1b11bccb74625a47b5655945bb934e38cc941a78697a39b6c109015b1528a8f1eb796f336770cf8112b3c49467f9c399899a15e0fa7dcc826a1c99611b7ae47f61f2709245bf57764ae78d154ac9e97752fda9f4494bd39219cddc7376ce286af9ba962e4c1c4770de9d2ee66749896aab59045e5a9990a549db52a51b58cd99ca2fedf1f0060406a1eefb42486ed2bb1cf3734c470e065e536cc29bfa90b29429743c94becad6ff9a62e1cddd6ddccac1486de1fc17926e3bec111eb0a7172e0e901188bb5154318d3bdd3dd880595a301f05f8851f6bcceb21f84eb133db894406c79af25074526d60a7977deb65d87fad800d4075e798499f988b4f58d84ca2f4c963d143d90c7e8c019090b7d2cb170a6527406c60b09b05700ae537d15fd73f2b0951b8cc4a31a0e57560f836c0048386d87babe3646fc2bfcd72233c98f9f659f717f4b14e9b21081553b59c7d99c65b826773b4b6e8449328eec63541d90fdb69b33a871e34c02b4e60187140a7f33c8bc6103b5865fd82d9813c99542eeb1f004ba924739305daeb6ff34c2a8c6cffb4634af3c6ca02c9de1240b045918cf15e8543f93cc6bb88f7de34e9aa44af7efa272f2f7010ab969744bfb7319f6eac29465be758cddbcddfadae4e24a20cb91662db223206281c33297f71d3dd5f0092982dde02718bae98ee68df0e16b03ff39a6081dcadbb4045b3876a3174abba0487e2abd6527e3efe5fba0316f43b313aa6e98bb8e04c622152b8552acdff0460695a0fc913d48afc250085ff24555ead21d5279632573808a12c2446ba8a2f6d5cdb6aabd9b5fa7e75d883ce2849e632fd025d22443644725127320f3ecb593f7ecf7a93a3c8ccfd800993d623d933fccea97cad801b89e8db234eb76f686f2de38309668de54b653b0a05610c65c729c219fa2916aa0a9c669ee0f0e4b8a24ca5dca614084fea18319500f960794f53cd5faf52eb6b065e979cf4e52d396fa010bd315cfac83a06fb58ec363bfeddf9cf70c8665428c8ac55bdd5abaaabacdfbc3bdac19cc19c2d7311c7709c65b3c59095ce5dd200f474f9d6e6b295d981cc7e806d52a8773b2e40ac5f02994d0e2e2a770de736b82df37ef4e4750eadabbb1e6edd3d9985f797ca9ae52ef6ce90cfb2c2e94fe98d0548308cc4046435d2d19110ba09f2b93342e799a95847d158bbaaf6cf49a0661520b6a3b003ec7067bb685c80eb00469991e21b2b2d30012a43bcebf092fe4c7957f41721323021dc3e5dc0e10c8a9f333376051f13325fe2729347e05166bcfdb52de42d081fde32d57ad85671537f17e59f3908549cb84df292f2e5c839fe56920a945fff4f54f2172c23c5de8de428fcb6be7cd6e94ceda2ef754758ae406430fd83675954ca9525689de73de5a19d13230db0ca6382234793c97037db0bf6ecf17183313e6c4e0b755c377bb74ff852ed725f436db7598b2b359a70738874efafd2341b93f8b5f1384bf86b9495b523e7367042f572ccb4165f3b98c17eca7fc3c1ba988dfcd941e06771413f79f88d3d77ae998cb4a91b88dc86a1cbde5ffa30e05a50d23f7b7c765a66cc780b68c676a0f48ae71ef950e0bc8214a6ac1d210ad2ad676de23b503afd86db133e10cf225fc2787d92fbace6b08c47424d4145cd41a7de90c90c74a22a928ceff79756c56f5099be7bed6547eaa3fb92a09fc303a9343db192d931cc53c8576fc5a7a7b1391246eb2025352aee17705733a09826e8140c8a77e7bcf0eb24d38c88cf64c7af25bb7b5d1d4890d9285420685c3a1332bdb00426e6450ed952a04ecce7c2ebfbb038b284bf39f4b59f0b089fc896dcb97b819b37c6ebf1e8599be01ba443aecfb485957ec346f385fa798e96430549c7b23ae6abefa853216c16986511a33ec72a9a4265df17f5178c563b4ced9366f1d12c2079a35454b46dda5a3517e400549958995bc0c7cad49ebeb2e8162a5146b90edac67242a604dc6be719a92598614975e20d11c17403b3b260253c5be2f25dccb88b5329ca6ed0ae1577c1865808e48307fdec9f6b6e59542ac70b1c87b40067199d6fa529fe8af5efa67e074f030974f29e1afcddbf93fe77c5b9cda30bd90c79ea930754b66d10ef37463f063fb808f905676a017189fdcc92ac5e5d161893984eb7578eac5ea7c0bb359ca4fb42132b55a932ffda20c56cad4f041b22e3efad250edf495f5223460e667a8d4b0e15945e44ed8418df9804f11e4a9a5345e6187fdf799f7622352aa0d9fb9cf4eea6fff41d44b699d504c00249e5d9a3597a3636d5038ed1757c96178f61973b09bc701a3df20f016cf468d3c558e9abe740e9009c34ede579dcaddf0ce8f2bfe1125d5d8e8b6de8d61a2355e28560866cc4eae15bb966163662e9b9da39b9bcb5c56c5e2c34337607086c40c3d91fe2310ccf5f1a5a19170856ee428c1966a9a52e4a5d317fa17e853cb0a02a4fd768ccb5960d9b5a4e11dd59ff7486d2c26aa3535be2b63c2d0b1f40890600a4786cb50ce25e7dad7551189531e2177b3c0390ff7df0d1d0f7878b03a9a3ac3ac2b2ae6f6716446ace27418c71e3fa5f9b54bc3127e2351c0009f359e5644ae64f791dd5c1c33aeeec81f58b166a2cfbcade3caf98c2d7ac19c035956b75e15cd42ed4dacd902e5b7bf857cb12c3877c822e34d279347655caa1df4dc0ea9ed07c5b55629a4b91653e3c82556bcad1147c397a7550b4116f8dde8a8e1da608b064c475e57c2ef183162b48fbf77942465eb228232884415d9339dbc9d95b761183785db32ab57137ef4f59827f08f2d444e37b703f1d8c4ccf041c4ab724424d79ae577ad6bd1aa011674d9b2bfe37258c14ee579c37ae9221e79c26aee2291ab2b80914fad10ec34020f302e81cfd80b65008f1a2207268fe19312d9c00fb594f6c854eed6c6d5c79c36bdd34f3915ec908994e04e96745cab10798c42388eb709bec1f419a6541d70221a73fdfda8fa9dcdf5b6817ecb8dbb5d38670696c64e3b3e1358bd2fccba4b4cee017ac581c0fb4f4002b7dc2c7b7f066f07be649875a59ac672f2ff6f0d25401fa75704a60d7c7e03ab712b93d0391b4a91b5f6407670f027cb79017405108f692943f6dbf48aa5ea6b81a454f04a3e4cbc925b24594a69451fa35b2fa251c5a4aaa1931815ef3aa3410fb17739b44cfbd8175db7a8930c5e2ba9bb7ba49fcf7d0b1a475f26a25f217f3e0a4fe3affc82557f4d84a2221fff2019c55ef86ca0cc42cd618399b5e78e61286e45b9e2c8b54028848323eb35f62dc70c2a7c17f83bccda8eb52c4c813c666c57ffb27985f548881987a3cb49941cc5e4431732db13977415a349203c14e662fbf2aa50fccad8bcd918bfd0d3a4a9bff43c1aa2cb5d58c64b69c71e849b7b5a8bc23af6318eef8ca7f12e721ee427f7f58f6f3d764343e38f8f9cba3e3aded0ad0e9a01168c1972ad9fc6828d211276ed84131e6e8c77a99b6cf2e33a4326aa4ff9a8efd3eb8dcbf9a20bb6e8f395e44577fccc67a0648afbc63c3b6660cc443e662423ee138a7113fa1755ab18577affed09f61b5cb4f808013509e7e92d2093cbafb23cdcc7a592a2835bcc29aa878e0f17cd7db74153045d60739e367e8994867bc7801d3034db3d4155eb89941beb7a17501838756e3cbe91f0ed4aca8445b0a6511caedef7f4bd51f248fe1cf5e4f4797dd5030c311b93399f2dd50d158b802d6cc9f606c3ddbd3622a199326e7d2570a688a7ccc25d6721dbb51b29d5b25b6314288a828d9d093d8bb14c7804f701c62c4a011ef4ab43dd4b52b4decb0a2acdebf2508d0ea9f4241c358cddf25d71ad5f2c4ca88f23c0060dc81b77ad0b7cf3553f47fd89d23a102927427622485bfa16d3e5f433cb779f3542cd3cc6ac28940222befc96790442c214d570b4f2553680707aed586e3bd3680ef4052aacb6f610b573afd249d961d00dad8a348d4d6f49daad7a92fa3a753d0f0e93a9cfc3868895be3652138af0f9a4e674d605bac9242f24f4d80318eabbe4aa7e53e918f978f4f5bcae0d61655d91e61fbe8738e6c0e89a5f4d511e956c8f170507cdb43194b9f39b6549a69a75e603d5a4f9ed10fe935f0b02dc273c47999138a706c36531e165af9309cb5b9dcc399c19dc1c5296b63be3e42e6220f0a0d055893b32db6613d004cf74115f2e93235390eebfc51b503280917e2fcf8182223053ff04e4131fe7e3c1f25267fdd85ac494638a2273ff15e07a4898327ce83b419ea925ee065ba529a13e63f23391e257810f0edf51c2488a7a2208a317f3ee76f3eb6882b3941aaa597671871c30ef015b50d2d3e1c68d18040bfbbbfb1d0513ed7f9995b6acfa46d149a131750e792d27afdca533e7575719f8c3c6007f08320635cc84f5e89572a9cc85a037d6b8bd50585fd58ed8fdf1b32630cdaf42e3e53c54d6119bb4e3875e495abab04604d6616d15866dca40db44c285fa6475445363e1feb3df53e6c3e169cdaf15c7699b113df0cad43733135be5bc95788d910432566f3975de9b83805927c991090b5048fb83ddf2de084f28850fde238a7167ae8bd5e84b5ddb3431f3b4b558a6dec4b2aa2654791166c89e6f168af2954e8f979fe1e37f7c22c13b68fd4902f632442506efc0bf93729c020d3afa8984af06f5e027683eab060e55d210dc51fbac1c8894b15d39e740596bcbe18a058ac445e834de0fee1115af0e4bea766bd7e8d04c11d30ee9c9b8cb08cc7fff0edc7fea6f865c4448be6c6dd65467d31a184293375e3cec5e438b52f5a695273849cb72a0b228923c326d71b7d8bf6199ba66d432fd25b4f8c2494307ad186c026b4677a479416885c912de3ee6fb994ed24d16c12d4460f1bba02e476c622cbd4b9cbe8b3e5d3139e26447ade2c1cd2c4b9914a405768c9ac464b82a4394ccfd7c173b9710b7787657eb01064e69b8609ab61d6eb9b9fcd3d524c252f1fc5271a554641ba457d29c6250db566d29b39e69932f059cf0827d806e59d9e209d59be4a515819a4c3b2b6de847b073f290ec2813b1bbce2c28f857afb904603d0e1df064db965e92a4edeaed06f40ad601adde83a899a4f803f30c6f9a22a63adbd9dd3b3c93880c71fd9af7ca9382a601d1c764e8148863a6c08643435a19226c5226701b291ea2c2d6236558f09783825e9b5c7c7a4eaa2c0e0a66ea33cf509d4a23c127f45f677c5a147addee2b5c80b4b5822f38d676a971c350b7a6f52241d1fb7aad30a136c16c5bd35da1bc5001d6dcc943c78611bb7dcbc4c4f2b3be4ef14d4a089f90c7e5542bf773a3640980257ec4d64d2841d2adf0d9932364a89118759f97b4ec80fdb87181987632513fac3626af0a6b853f1a079e71869e52bfee96e86d46eb9251d38b56263ec0182814a6282b06fe14faeab2d68bbdab3e67a521798c62d79c2842e1b35f9845dd79612877f67f42f8aa19a6e34948ada980b4003e8a6aa52eeb3d20514f3a7a7248caa64b0b2e9cfc8fefa11d6f99055d7dccff810a1cbe0b6743ff1b80892e7bc482451d2eafa87c1b2ad4378762658fe404369f9ec97af3064c0ac0b6772ae00919cc25221c3a6c8b0abf6157e333bd5f65c97d0177ee5c0e1c1e20505dbdc4d9e0ebfe1d1e3b3e546dca1c589598d0deee164b929ee8f9032e77abcb222c34484b88a00b3c5d81c43c40c0c4d7e300000000000000000000000000000000000000000c131a20252c3137", + "tbs": "a6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f69645820453222b1efc1868d63e35c005b444fae9fbadfd24ee2bd54cc2ff5a58e6197b1677369675f616c67694d4c2d4453412d3837696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f686173685830ea00175beaa98b68ec375ce0fb78f6546217d0357c54a196dbeca16d93371bce98f9dc9f7ad571cf6e7e5408ad231112" + }, + "identity_key": "8ac2c0370467149d6349aefa82a2ac5e8221dbd28a094c30e66d57b48f38fbc6bfbeac67f7b5f55c6ba996a211279b722c16dda0394b38fd6d56b1685d03e0183839ff2fedc910f108860568bde02c7d1f23038cf57a0526435ef93e10b5d0ab7a3171c6b8e499cc91b70eb6cc57ce4d5df15db6604934c4a310793858ce9df56fb4bd3b08add28c9ebcddc2ddb84c159470432ffbe6466a4450bb27402cee70a91da55b16a19802862fb159f92ab64ac56539ab62bd5590434de189430015f7c68e14e40f4fac05bd2994794a97db594f037c02600f4cc2996df32b98a7efa07fdcc1f62812a7e690eef63524bdbc06f5c5c94e4decea2bc2d04c6ddca5b45e81944aebcb44c84be9e54cd576e6ab6aa8110d51b44fc8eb58a03c3a22fcd0b41d0e56b4b0a0ddcfa8301a3dd99a9ff4f31576e86c6f8df11cd48991d812e6a4dd122441be3a4d7bc093ffb395b966b7d6244eb57518484f408e361715f00f9ff9c56a692f1a8e0b883f7f0a81a5b575c42c15348d53be21cea1d32db19866db9c09df9a47a85a795346207faccf2b928f756f5d131b178be5f68536492b514d5988d028af42ef78b142f76c31fc00b43b65805583021fb6738b756aec76033504ba71212b42ead5a852b32a6e03cfe3025f25027fd456a4d6459434f7b79655ddf503ee0714f733f4454a85655e9123a48a8e9964f86474f072e1eba62d4d77cbd342f7b6797de08833813159789d9ca2f1c335d2f737611a4b6e32af9b0def3d8bfdc855717716934a21cbd120ab7524bdee460c5a276f858229f08542298546ef597c3b60dfcc35d1ee112b87987abd91cd75f0e29a1788657a41488b2bb0bb7395956ddd340534b6f2be80289d26f5eb21e569929b182a5ee334f6f474417a38ccb62c67941951d20d3b1ac6f157a358686461b1d49669e60a5dca5083046f9f92d5b9b73d1ec028eee2ef0c0b7bc1fbf5233c3d4b088d41eeebfd91453e39e9df104eca00f0b428b6ad7d42a3e9848b43a82a2ab653e126d870d81635c7e1ba47bae1f06740bcd60743c0b58f6bfc5e5e7832d5cba0f81984146331613477925ccb05d30527dd9d01adc0c22250d6aeb4d33e804758c218bca76fc8c097acaedaca2dab71e8e23564b1f7eade5dfb7a3660a3fea7b2dee406ab553125b3b1c80d7dcf31eedaeb3715588730edbeff60c513df8bb91ed82959c22141c250241ea5bfa1f5f603787cde94f3354a91ec5c7392255ec5c0b573cde546ebb5430f80a855937c09af8fa56cc5a512ff64c42cf8b27dbf45a777a6124bdcd8a9ccc0cbacc4b2f5c9199066b4ee2723201b5dd6323da3f02e72bf6cdc4e420ff8e01662d9770d660a4b45cd8eb83db1c03935a57472aee22569aa31c3541cfcd8ddf0e999ca35b301251143baac0f3562ca1022292d75a60eceebf5c83f3be0c439c187be0b29caefcc624101226343c813426b5b1b02751d0905b82a652f1f21d1e9cbe712ed2036b81e55cde638c453dcfdc1bd701ee184d6ebb245c2b43ab64a8818627b4378dd9b8d1357c25b62d2bcff6ce6cdac0adb1eb446e5764953eb083f0826a5fb89f3300b6694031e5c9214ff11751d518b2ab3c7d095518eb9f23c3a6538662f080b2965ad608fd12f7655286ddc8ad11b22b462eee91939c9fc66fe0fd680109c55b06f7c28267837693b80111e6306e991a9b50e25e47709901eb31e622a936ee5e459315aed300c383c5ff50141f343423f8589ddb58509297aa323fdcd8df4c177dad4b9ef892fa3bab00fa8bcf82bd992c8091e6fd03edea2c12c84c3f6caf7517d71fe9f3293080ee2a6d9a6563ab3444c8f0a143a05da9623a16cd5a13a7934400d9e89863f12d382ecd8ab45e9f6fd0b51ce9c886bf74cdc45f5779faf4aae7495046c7b9a27e3e1f46d22b3904c6b7790f8346d7592464e15177dd297ad93f89ba929230f1bec92c96dabea155c00038bbe289d2c1ca6255051b5e701ec082b846c5d50e35f4256423babd79a096c1bf4d30654745a7dcfe6d952ea05409bdc1218472b84c87a92077e16dcb9b344ed9526518e688e5f2ef2c88cce00526d674bcba22010c8014dc4ab831ba1d5b80d19dc36c981bd2fc51c4c6b21a723a8fa1687771199198b662212a8b93110e061af41550a07577793f1d2963e4fce5b75f246ecadd9a6ee67a7a0264614b25907101578b089e5a33b469b73808d44b3c883cd5e88818f1e2645be83c9569be39f18535409af1247cc760d03b5baf4307d4cd53189d6c09ff29097fde8a6bedad5b90b1603859595508d7bba93141fe11a6381bdc8d6ebae342d03a2edf66983e45080ee94b4cc342b0ee2cf42c3c866afc178d5391820bb962b766a253dc6fb8406a3aaedaaa75c99efbdd340279cad25a5145d5f77b9b0413ddb9f3668a74592b3b9b21da97fa191446ad1792037eacc98ac5506531a4e4210535f97cad1e70dd8e35225d81a406369443f709a73d473b01dfb479eda461e41f7231d9f16d7081bebd6d1253f3e2b999121c571b796a837fa2958e33a8a330228f2272d424683966acd3ca6bf3a3f390a4d1656e47cb070a17a6a0c976ecbfb0a242cbc9df07a3d1df8102ada00a9e3bb06c845de992ca5645873715eabd7efee8c590a0dddc9630ac3ad4b85a527a52bd5edddc54ffaf000839b7cbf11187f0376772701718e47d9b160302ae4611f3685f1feff44aec5472b8425a5014208d08c17c72e4d6fe1092aebc564b09439156fb859118ec08cc6691db28b29ea0e85b229bfbfd5353660cca284e7ae68329272fdd301452fc7d628b380376034978d428c2ae43835cbe14d7f5ef9008b29bb517cb70a81500a3af369e0689ec661d383720285a10cfa9b106c86d01ad31272ff74af81e84e2fbfbb1148247d50c69f4900c155e7488e96a8aa25852100a4a5f6f71a4e988b1b7da3dffa01317444c88362986d61ac8aab7f7887f634bddb9a687310d90c815fd1e57f0caa8c97da3b5c1e759c67558107e99fe6a658276d7359ce3537d8bd90d27a2828d61d883269484b7de97782a11f04f599198fcfc3661e8a4fd8ffd1294b31081b8128f6d78fcd18e50052832b890f971a3005c678ebab50ed9d8b3da2cd2d81f688cdf058789bbb179939487896cbbb795648db68af06cc15fd44a62cb68e9041aad151e81c54aa1c153af7e7b75e0736d25a8eb57133d6a73e5c3e1ee8f6dfa93d5f6a9f78cc05e037775b96c4ee4d84e40659ddfce2d83db1728c14e86020f734912e72debe7fa04793e1f8a6f87cdaa682958ff4d1b1e8b2e68226a9b6f88f4a1c6e5ab1babff0a8368e265eb2b32a1dd9ac3e32aabc63dbed5b0e17be43e73c0a2da74f88cd4953f9c9ed32945e3ced0d0d5b4e90248e3abf1a38b8951c1a02cf532583ad6ca3eb8ed924eb39da9036f2ba8ccd3fe3b2fd5970533946ed4a42d6900dd74d74862a841c405cd6107d77029287d99f3c1160b75b2c55e04af58299bf7f33c29f5ea36bafc6f83078f8107d924d2407ba68c1c0b7beb3af4b04a6b29ad9f0e94a8ef59862da45315d0bdb16f0bc206d93346b1f17ee479061a361b4b5e715eb376dbbe37084cce962d696fdb1913c7af479e36347a0b3f1d2f49ba5303ddfc53226caa815c651292dda8518", + "leaf": "61206c6561662063657274696669636174652c20617320697473206c697374656e65722070726573656e7473206974", + "node_id": "453222b1efc1868d63e35c005b444fae9fbadfd24ee2bd54cc2ff5a58e6197b1", + "now_ms": 1789000000000, + "profile": "pq_pure", + "tls_binding": { + "signature": "ec94c843ff4a56fd54456d6a5fc9d9c4cedcef5698f45bb85b837ba5b8b3a97270f168759295fad482eecc002fda9a60a23919fdc35025c73df52c2b62ffec1f4527e5bed6c816b0d904894dc624e08ab8a24578d4d21a9de3070a48952942f989e866ff48576f3af80b3d301380ed789a8650c8f3a178a96775ae7f9214e19fd2feee670715f474e3ff3ac90775e23364282cd1c304296acf85ab57d43e4bfeb16a69f698fec8bd9a4db2d20dc409fe72decca881d2abb594f4faafb3f6e873fcdf394417280cd8f27d0bf7278e1f4f96210f42d411ecdd316477d5c13045c7dc2bfa3d35f5a8768f7c029da1f3ae0f06082189979a33a4fd967c66a1456e62b7e4eb3c89262302493ba02070b00fe4343655f836dec75142d9b90304e8d29f7f32d5dbe22210741a55729ca55448eedbabd0be316bd53c7e685c529fa899a48270334d08b8250238790d4c556ed515ee4d3cb9da8c5f2f4fbec029d0d67b324ae9d3d88f3669e403cb758cef02f1492759b44b6ad2ef50d81c59643b76bda82281bc4aa2c95ff219027003c8adeb0b139f8e1bda642cf1ad23646b9580ca37e852281598545103823a87d328605e3dddd629a245dc43a33ea0c98709d59ffdd5a286f8d04e6f9f3ff778b9701932b7b48868a3541ada5157dbebdd522b67041d6118871b291c6ac7a360fe2dd012c26b65cea7fb08c80c9c45cb0eb1cba04c8c1b0a572e83330d8146e2ee8a232f9372203db88afd6ef65a82fbe337806a346dabd80432fcac079a8982b75a9439035902b1a6270513225721357aa30fc913c638d520826301c4f7f06c0bb7b9e79a6103f71c5c04ed00f26dd40af9a10c88bd1829fa1d4f1402d40b4f09f49fdf19377e7952084eeac1eb151fbe356c3844fc4b6001c746bca25fc4d006cddd667496ccc65da177e25f458a92c10989a8155f734551b8dc9c1cd9fec7a67fe2931ba42e942b6896b042a3a134b41216d83be79ab9e7d401d22a76a698bc428b6d62b9ff098db3099698b2814ee083d0a3f33e9d2f55a1cadb7da475f8f596d23125d9ea4b47b3dd4846d5b6ee58c4f3c615dc611c881a87ba80b37e66c922747e268dea0aaaaa3606576eb2f685e3d77b316ae37ff54a80c2d6a24d2dcce51ab9edc8989d52e85df13c91d510c746c0af321047f2453f372a9d924518aebae529311cb71806b76ab9520996cfc831b8b8c2a32333e0dd9d2a6bb71e1b63bd25ea58ff44cbacee5dc8342735d3e35350e9144a3d0ed0502bf0156c41898b766892085c28089e3588dc2215b228e28abb98dae2210603d541b62efdc590c372c15cd17fe3489f6860ca74a595e6b74921a3b1ddcad60435b9df3376cb24eceb6be06beee36c16eeb26ec1b9f43ee546108c4478e1a201bb0e300650f257ad46dfdcab60c3659a78d643843d67bd8545ccaa73c1e5df1a4cb79045ada2880f937200716b15646b7f23d4db2f0b2c431f60f833bba362d473fb0a5e4b5f095aea15ea8766622920e2a4101f9c75ef806d5f3fc492f3672fac0a8cb3648a4c25737e71e7334127c7befcef8f218f7bf3851b11cec9fb7469467366e8c324f251f203ea8c63a1f6fa79c2c7e2651c4f636255f26933f90cc19f2ef6e078c9a31177d522eb1e0b239329a42b4876dfe0e9c9d452a155962bda7b2e830b65831e11fbb753474dd1e31f4596c432b7a60393e4815f1ac5598adf9b7c0e57d8ff94c973947982e6d1d4b353f434ddc58feb726dc7076c766b353cc42057665b44ffab2de76a473e4dbad9297baccebbcccdb1bc668aceb747f17ffb831436858a0f8dcbb9b2fe57ac10d43c2546ec6be2af2c8320fda93ab2fb2710737e2b839d410ffc624898832872784d28b83f3a932458fb9800d3400124deec7bdca1d5f5a0fef442c6f313e5068fcf3ad8d3abf168c9a00a55aca2295cb27dc0f012adeff59b142486b23c058f3d4193fed4199c118667f47f98b8a71919f797aa9b78cc39d6e7298d8de23d29b3ace5820261636bdc25ddf24812d3b9b798644739c6aaa96899232bdc9ecad316394afff8be9cf038ca986dc099044d37d0c5a41ac0516b9f2cb6fc454f35f0dc0d1544daeed2e0a93ed9175598031225b9d673ffeefee9fe4f979925ebcb349a8c9c0d4267b92ee30aeede008770c6bcb9dad5649560246b4b3670c4166584359117ac98812998a7d7c4220020060f1823d0cb9b1f5b181917595b6b7cf8c535bb95aad11cd07a079482b3df5f1d138c94d7d7c05e1af3eaa53989ae75a0681cecd40cbf7c5a81304c6444e5831d66d0012a66aa3a5613410b13e05ff447ec8b1f99a99eb7ec3d1d09c35bde743e95ed88b126ababe59f3aaf4025468285338810a917bea8805aae1b71d6dedc31b1dc4438ece7d97b7a6dfd11c9d13da86ffe9fd6ee543de28ba964c8724f425cd64caf95f1b581cc6e530efbac0d745d3440ec74d80eee6d351f0506550658f8452668f188d5d0806a12062d97f5e2a9bd29c0900ce35225242d1a9f2392fd3834ad92100a72b2dd28da36cd7a363636d70c3f8eb6c1311a50becf5f9b3e72f6fe2cdafd763e229b928dc53b2111fe67b7755696dd2588bb39078c5aaf73ba6f67cf91601a6303b7c7067bf663c0187af70b6fdc12aa5c360f1282eca896569fd382ea66975f3da5f879564c1a8fd48d1f497ce88fcf5765c74a5a04c0615ed53c588162fa936aa4dca13dfbc43c8b5c6776da4638611615254c7018e48f6d3b880e5a9ce17ed53dccb95c695758fa5612cc6bd43fd94b2244a0f7af4c63560001cff178bf0bbbe82cde391f35c5a90592c8d5501acb7b1609be351ff2a8c9ae2e0e522715bda63a3a0d7fe23e3d897ffd970d64da392cc334b47b994ed76baa2cec6f7dfd28fba6047309469d933317b71c42eec69854f5ab9c7185a6c62d274338bfa7e7f938d96efd444c35ce4c4f0fd4e8a855c9f481c469c51de99d558876a7c2cfba29078480d25bd7bc1a0c6268cf53e1b72195ad9e05a3285e9736ad89942874ceb8fbe576dd507b749364a62bc4b87b9e87b75a2c853daffa55bfe7728614840e57f74d725a68bfa5dbff62e3d167909a12e3fe46c0a6e772ac1007eb365d66ca8a305259463662295154bba532f469866f0bcac13976104891a9b41475ac53823cb52bb6edd403d55c492e46bb0d0f1233222ff18e8010ed1b92095dca7beadb4833a46df2a9ae85152cff18a1909d0566a3dca0599ae492e55ea2417ac1f025a6ab2ee8641a8f883277016c999561f760e7f711405f974c19c411f7640a9904d6fc6093b227cb7b02ee32f4781d298e92348a7f53eb1dc52b05da6f0a06cfd3efe736f5c13fe04af641e51915eb3ac1eb0bec2aa2fed53c85c975f0a360a9081f0423b1d1d7ff7fa5ebff2d11b7ba25e6e2b533b9aa03ae84a939adc59a13de4d5f936b95f4f2bcebf34d93d2b52616811611bd36913cf4770dbab18bb4746f5242f5f6c8e61bd97d88e7a896309f23fc79b7e7248e8c8017ad1b073e050b8cf1a4c379b29dfb3389321d7e7e946ce402b2ce60f7190ede7f3e1f8527a6b3023c93a962b9175471b35aef1bb15be5ad07e62a20cfa6841e0e35a8f60d1ab40fe32a3a1246432687a9599f9f131d5552f7bce1ac8bf0149a18cb92638f0d5610410be4d612761109bcbdf81d33e3eab14546698b76bbb8c8ae00380f93de4a1b17dce541e541bdb18c2084f7c63ba363f83c1d33f92ff1a2f446e689751defe274af4fb4e99a21323dd7309e7e9169e260c14fdb1f75d90a7b1d59f69754235394b38c58136a56e7379ce5a1463d6b804194c24fcdb080b503785f9fb827770122760ac920df636e3a5f1e1b841e890b086c2cfa1686c61d6e466d752d9e070da034f6ab19e46c50b988cd5e4e7f38453a0081c025b850def16f2478ffb7eb4fdda04b80b91d0b962f097a5bf21ac25c526e3f4a26f02fb3a523c878de57a39a4ed6fca6c1fcbefcdfc39184b02490d72ab600bf159b1476392f8382eedba3af223253c8c2b0d510450e1c2cd13121dd20912dda330b8f1a7bcf41bde47fe50c3b14990d778f7d6d636315c6deb173f20dfa9811859ef483c97d889e03f9b12612e353325fda9027a79f7bdb629aa16ade02d8777b2de406f83b7c0e2457cfc72ae5dd811575d80cd0c28c086d08c3f65e3606dc4e589447e9e56626b23ea27ef558aca63d5bafaf633ac147e02f358d4b81e2a05738a96e3d747c1dc38e8692c96ea05a5ba1114677ba0990cf233dba7c9c0d35ea3fbf7fc2d0b1b9a6902ad7f10a15f584bb75b8d55cf6de8683ce110968f4d18aa26e7096b4a49b9f5a9ade0cf785cf8de06fd6b7c4a1410eabcff4047b1b92afd1f7b90a0712a09b2c64d0e88893174d0feec584220e6542327bc654069bca5fcc847303778132f8d4c216d04cd4d4c8892d4c14f63ac25eebabc2444bcc9029f7738d747e95f1a1b0e6bbd85a24913bcf5f6b84b0e368bada6223674bc4bf3f9ee759fb4e6db1230fe2df8213cb7a59d1bf0b3e92ffccfa699168522dff52349e425f2bc3c29c986907b8362c1cbfe33261c76beaeabe0b9288d7bbd8a5d21a01018224d3a034922eeaa346b1ceaaa00e4e1cfeaab7deec6c722aa6e363ebd9ed927819246e7f9672060a2467aa0ecc0bc8ab53fcf378026463a4e48db20abc59ad02fe5532addc1cb9093145d8c977a0485f4305b9038f0f1e20eb7e8ba3ac3e830bd9b95cec50950676340b209d7d1de23d8b65cff0f476bd7101045cbdda29b0510e6c44386ca63f6c6832b90b19f5d55f35b50b9ce7c72f8a0e406286d5f2eabfe39fa243d8e33abd55f6a66edf1ccec39cebc09bab7fdec2f47c184b0991fe30df245d21f9cab2ea12a478b36c799a18647ff0492aa1e8d8ff62aa42df8efe56344780127c48335a9598647caaa64fda15bdedc6b15278fdf443047121bb5c13313002c9dcdbd9d36c59fe47a27b764f1cb57a9a1d6c0b831e3b6cf16c801269f6927545d419a7b67856f85bec355e0629306b6bf569a7d02f6ebe453f01205ac92a4a21e47416a2108258154315dfbc0af453c8ca82a7fba798b1433811ff1a828b3a0f2a802ca17fdc7b3145ca379b02bb21223571dc0e8a779068d0f1a3056463cab5e49ab4d25a8215417615ad93de3f581de640feb73a44624995c21dcc15d9a15201b693bd0796cec41adc099527fa08b23fa011925dcbd0c8c8858f75f2704780d849bd000a2ef6e0fba4abedfb519d28f16a199eaf48f210ca1173a59ee5f8225592305f19330f8ffa0ceeab0b2c7999d1afbd3e6ce5418dc466584eed9bd211a6c65d9ab159ebd4aa4ffca8b4df39b23c44164bf11d5d1268401af1e71134ae0a8128297c54d5d333e2753cf8b20618bb6a55833f47d963eaba3f75162227289f754be545925272ba6dd1a454a78e6385d0440b90add8fe81a82e9602e5a6e9ccd7bdb158ab42d6890f28259f0173fe9079ade862bd8f9d97b59a7db36e5a2bc93626e68e5484e71f2f98554842231097af4d16218d328b45b0e6e9d1d3fe66d999af7e3a857677a5c72c4e460ce1ddb085e55570f278a13a78915f7db0c91f1e06b73be02489168d48119bd5ab60fc3b14e58453cd61897c37cd13c002d1f2a22b3ef2123d2320649f49dbf8c903583d2e74ea40ba843e0ef1bbde7abbbf722a92e8e288ae9cd15ad941060e1751f7c09cd7cda82e45f980c9cc69784ee48e0f76c6bb05ad01e4d4d850281f93ae13c0896bd23b70716ce5c3a3f52068bfc57b44a3782bcd01138b331165b40183214d153825843155b48e248d4903783b2a43f3c7b50c883962e01d0d470af3bc62c4917c508e95cabc1d76ec5f459b649f48420b21dc7890ba0a1f08bdf2d46052d593e3345c8b12a8f6bce8ffb0ac9b31a77e17e9fcca6f559d99c6f9dfe76aed3b1747ccef1d1db908f9be94075ed621071b4b00e431d31eb1dea411b0f2fa31da1a5acf67eb02e98352761c2754c97f6087791d568247e243969a0cb31ba0c78cfb68191bdb7ec9d361d549d068ef0cbe631c9b7d3a2d8fac009f2488abf1a4611c8175d56a031b6177491be46f9ae9c729c384f12a371babf75e694ac6a79ec16f49ae363780aedc1d04c35eedfe3d7684897d542a9d790c0d5624a3141390f0b6c383f460069c5b93fe892e356124856eb0c4f0643ffe958d5d3dd37a73a0382b2271f7419a9105d318af175e6279154944255c029959806db137f2a6af1d4af8b1eaa7e197aefd135c26a31e8a04ca6e88b6f36ef099f58daa72fdc4c856ee933a3efb7c44bb59f099d7e67f4586b1f73e09bffa18d7948e33e82fc94be20695de6df1f07ba494c8aa29fa5a20cb843992cbbed54c7f349c32353382319221288b4c33766436b49267da301d21464a637188989de4ee333a42518f929cc12b505ec5f21b204345646994a1dbf50a0e212e557d858da0aedf22378db46066798b9195acc3fb0c375b8fc9e2eef7fc00000000000000000b1318222d313a43", + "tbs": "a96375736563746c73656c6162656c78184d4143554c412d50512d42494e44494e472d544c532d5631676e6f64655f69645820453222b1efc1868d63e35c005b444fae9fbadfd24ee2bd54cc2ff5a58e6197b1677369675f616c67694d4c2d4453412d383768686173685f616c67675348412d333834696e6f745f61667465721b000001a0acc226006a62696e64696e675f6964506333a28eaeb18615051b69ae664161886a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f686173685830ba5d003ee50124a47aa0d55c045bb81cc4d8f48f8f2af53deb5f9814f14bd78ea652cab197fc2348547d4cde771bf49b" + }, + "tls_status": { + "signature": "635ac5f74446cefda87b297699a75311b3feee800fa4d27ff4f4af56534d051cec9fe8b0d8b1a0f652bb3a7189c16a402b5312097ab5b692366855e426d55b67188c693d7e9e132858ff4a7e8359cd892f695ba6557d6457e9dcc878d2f9f3cb4ac8a6c72baa1ed8c781cff7e4a78f28aa359430ebc885ef27d7599decca53e7ccae296805a3bed63bc0fffbd1464b4ac9a0a5c6b77ea3d12447918bfb18818cfb2c92de0c3d1b9b305038a53255be63e1c7f9ae8c35cb5d519f39a275b58cf1cbfa6469d465af9f51cda1306fa50a08605bee8f49fdbcc4eef90b569b7e3e9ccfa2b42c2403405dc7ff315ce6c0e4ab617c56355a669b8d4194a12b35cc8e7b86b47ea08fd79c32f878d0d72aaad67afbea2555e042282fb4b20a3ad9afe9afe8bdba42233a798e27575f7b02cfaa29f29341e6b67df53dc002304a3165c9a56695c4b4fd0c4cdb9ac3c3e2b9d0fb7e0f5086755e74e4c7c8f326d810c7e5202b3fb2f94d7bd083f777dc11e61897729b5c1f62fcdb23d0ec07b14364bdf1edaf8cbcabb1f3ecb5b58d9420c6a06b6f0dec04e8c63820344e9b9fe716067c0fd4cf31131dce324d6a4d9321c9cea11cd066b056e490d7d6d9ad5d165a3e1ba84993126a3e4347ec1d892b9e3e9157919d3fc3511c40e1149870c6387ff7c808dbee87d8306f139942037089ab8d59534aa0d132ca70da5b3ce3a447691c03e224d30465a45ee19abc27120c5608ef65c44c74db5fb8b4046fd004819c2865f9097a4eb43477cc5abe6887030b0008aa16f1b9c0f980a55371e49c5770855e39d35cac559487fb7bbfa27814e0b2a5a81158d829253cc5b8aa857d1930d47581a742f9e33d7fca932ea564a87c787d8c56fe736d6f2545efaa6edeb6a93ce804bcf76fa5ff320c37349854cd3c17026d0b8c7378579baec5cb8e864f19d3a9d28b78722303b6c65dd2ef0a20f1b78762166e7bddf836066deaa821a0d2f7d0758661ba8a376f64112c25b1d9803da92f29df7d15ba02534d32428f1506740b8fc8b5ce212354e4db58d9bf1c8d0028c8b0479db0bd5325fd749840c95156bb92a2295b60776a8e012ca68c60494fb41dd731e7ff919465cb6813c810a9debb09862dc722f550620b1a88c502f72471e05600fcc95240f512ac30d2e1fea5078642b398cf47896e11a53799713502bcdefc06c8df1820e426e6c0f3b748dc54c658297316e23e542e1b74240892252311bec53f6795ecb36055d2ec3afb2f998cd9b5ff27fa96ca2220438331081881f6615d41e8a7825dcfba99e52bd6f21f6a398c92f2caa1a60fa5795bb7689333b932f61d322c85dd9767a168a58e4707a63e236617c0a4ec6cf1e0c7fab7d6b2baf903b7ac436c9769236dd9631d9253674aef39f358d848f7e092dea6dee03b9bf4ffeb96a80e5aa01f8e60be1dd0986eb0176eef92fc6bbdb3e810fa783d825a717092de6fcbb4d3c6f7414a19f4e1f4347851f10bcf6cb35c193dd1c8789206569439724b4602f3392c9de55da54c7a3c130c84e436ce3a93d8afe55919634211c004887714b97686c8ace9eb47990f87e689d5d3001e2b228c4c477dc889360ee57a23febddd4ab5f379fb4d0e706ffca9ee410fe33dfdc9df518aaa4d0cd2fb588d19bfacdad59752561757cbe8d40ceb70d13cfdfa89276a813002c4c10406cfb4467890764acde13d80db87dd48a27c522f8cf93d9654d6b1573c91dc441404bfa2ef5778a8d1d7abc594ce6e642dd8886ac004360bbeee97443a60c73d3518e16d734675711ca81d2b596b6141787446ae022a1783386537a5b676f132dd5fdccf852cca7a537503e028c3acd90b0e8b15c517f7db6f6ff2af02011ca8967767806e5d3395cf2baefd4001432434d1fdfbb4afa791631f2d191ebe46f42a9087dc1c6b71dd51b26a7297c44ad52fce6fed4ad23ba1d16f9a879075a2501ae004e664698ac66e6b249f52a2e65cf8751c2da8b89d29a2d2d1d58c0cc059ca2f6abb8eb4a87df6609136e775487354c2083efa92e762cdab3ee3362f880329ca719148c784504e2445d7eef8f4007fd2b858b8ae36fa4046ca6e3d3c1bf1e03c9e89db72b2d7431d3a74e2b984f10ebe9ed08fec24724aa1794bcab61fcaacfc7a62052efdce82161f24823b787eba9e0baa1c4a8974a065160ddf78099f358d45ef7a65d47f689a7cabb6965e4830ee526941448da6086d45b7ce6a128e5c17dc99adc47d113a023d5803b87d451c450572c43bae1e57b0764d312da0bbb2fd5de8df8f20efc7373421c37f281e4c64e64ad341d638b866b38848d6e660218ccac69e10496b68cf1c4dba33d5c16d3cd92d677670278080e95ced6cf14d41b0e2e807f54eb4637170659915245ab393eaf805da6e34decbd1cd23445b05469283a8e426215dbd32089e40ecff4c8a079888dca093a1be1406d7dea1ca1d8c508b6b6dc8d624531a977e27599d3566b3d4848d8b8a7f429bd401b9b805c2cf78f50bb72b0e3211359be0328638e935a6e97aaf4da6f0e7fa0760c66012527faab5fbcc203081125bc4e3a93f540f87654b37d8f882af925e46bf1e895a30525be2f38affb5b1b9f4462fc2612e056d9153b80c28ffa4ceed6e67abff50733a059730e354bf71982a908a5f68469d51067338ec08593854744cb33b6fffefba1c4602e8c7afeacd2df444b5881b417ab411c3d7e9893c277f39f4256d63a032c07488d86c3dd3622b4adcc677f19266289dc5c6fe927bf662ebb8409da466d931179088c842ccbdd02bc90f47b952dd6fd22545311744ccdd2d711324735e560223261a47f18b985f5ac24cad2bc6e30688476b0db76bd35b1c098eb39daeb4ddbc9ea4865a24fa5fcdce6199e71c598417719360fa1d6d885999f2713f9e52404b6ecf0ea2b0ca3fdebfd2b4a8dd6f40d504b609e55ffeda52d04fb331e64fd5ac7a03ba8363c25cdc3aa0fbf88fa1599232d4a410ca815dfecc31a35a2d69aaf18b48d18fbff12954a718153c63eca272ce6c76d124d76666b25f70036bd51570a3c2ee7cc6d16e6e5c4ddec165032dcfe5daaa4eeff4a193f9bc4d42ec736d8f60287024019232ed954162d39e545f9c99aec9e471218e315173ddae6925d9d539b6ab7873967114250f8aa2b6ddd6f766c1a4485b59abc045af2e5c4f3bce1c9693ff2baf2e7d2d58f500f439d966d32ab081dea293090e5bdbb3cdb52be0b6968f4b27abdf11ffa36cc4ed116624e4ede93e97151668dde370162272fc74722ae5b9fac6ea3a998e97c3190262812405a4ae03fcd71a3d88dabaeef76323837a8da01c4d0a5acf727697971bec3294b0d32ce7dc3824051e6934becda252e9054986e0876ccfa6b28bd59fff5d0a091a98762edbc1dc19b6abb922654288354d4e29c1189ae25298931598a5c09e791cce8b7fd87a0a625e291bd3b498fc715d8d63d41f9b596d852b678b5475a30110984cbc64d41c1cdadec2899b309338b90d59e6c04b1c96bf5b3e1f7cde8d6dc070899c652a8943b4d95142694e860ff6ea6ecf7bf790fa51988a42022e69989e2fac965e2852b406e776e8ac15f599786c79658e8fa5be0096c95346d5b8d78ec794f552104bdde6e510103d54e35f0adb8503f0c211db393fae6dbdb1e90a861516ce69e694f0363bc7cd8a753ab7c36c86ca6d791b1045927a83dd622eb74b1560e706e6bcb9dab0d264ad9955bb0212e2902c37803ddf4cc2c83735555af767ab817f600ba6041f2d3feb75b9dc9d3bcf88ebb586e1550337b2f84be51fe251df0baedf0f875b37962b33f67a26af984f48c533508a737808f96dbdb2dcb127a0eca9395d739c7c567478739fde2cd61fd3a726bc5888719295e6e078aca2e9edace87008b5f73653526a0fea0b5a77cace2f05403a4a07516462994719e55b4cf989211c202f1dd24d50310a1a84a591ec2d4e7e6b636a60dcb3c26c0ecd89373f52cb76bc0757fb211d650ef42913b1e54118b01e8ab729954155fd79ba9b63029c518d43cd80d25d38972bc373d80c9eb5889a69d1cee27b593198f0dbb5744b259b25814778f75a62056f96106abf779b657972d568c046947f8dcb03101154a979341a2d77d4a0a608d28e82a022c2bcf449d47de5bf7a4d7ec91255df304ab4d96b3a583ba7e04aa996998105666c68e2dc0bbb5b4542679108743fbeb97c1799569a566cc74b64a5219c5896160a75d842c64897f6b90fc24f10699783dc1033aadb55f5db69bf757eb3a36968efc230e04a33a03a8e0ddb796b8a59b61e9ba8cdc3b2d0fe404801b01bc954669215131bb6d978a1dd42b4772426d368f1092d0d674ed59a7162e5c93ec7ca24fcb39dedf2c538b852b6b7a40bdc0a783530823c10d98876d2925aff51286d87d2db41f4b8255f033932d2c89d9041fd8ac760c552fe4ff9bf8893ff2737eee2e7b29c563fc20bbdf1df1ae86928efa77face319c59c2f85bdd34360979b1ccec791a878fd7d3015d06975bb437dd2a74b60707ccf608799964243ff9f370f69ba6fba505de9245355b6b88296627526baaf308fe4f5f2e93e23bf0e7f42757ec3e41b3f8edf6ba190ffc132e2182e5bce577ca4fd8cbe94aa043b27d95917aca3d3b3c0ff9c10338fce053e738dddc39fb2b7fcf0313f0b1a3b04802bfabc90fe6d62682774b37f8420f87811c5730b8d4b94b00d254eb451e6120a5b04c457335c5305db9c76620e6485cfcc5832eb3c8ef32a5be9bad4be0d32494a227a59b53ab59db54179a9d4463fd3e696fd76a360364eb7339f06d4f417d2e15250a0f9e8cd3214f30295da3f09ce1d1616c8749d5e5e2827494e45c63968ae5a54762268403bd42236ab7a223c2a57e46fcf8e6528110d2e9247152796d179a4fcefd3d63cdb648fc8d244aa22c1b8331c47792d645eb8af78dcb2c3733c6c59bb013dd3a200942427e4a044ab6bec3b4823929c05ff61650f23070f7d717c37324fdad6db9fee06a4da7829effc7e393a7b69e64d2524ace2107ea61c72beba384ed4627e839416cf5215e956805c664841e4987b53fc1042072712020ae2d52e316b55c8f0a0b8120e400f2d464ccf262bbf07a582ad1150a6d91e7561941e2b18bdf5628f0d91856cd7bdc78aa3bc1ceca3b911729b24f200344c23d047dc99102d05ed44e3ba187a17c8ecefdae0467cb1d9a5c42a7e0c375612ed09902e47feac0e8ad5fefe2788f88442262d18048d7dd98a0afa8615fba350a90a12f49714c00ec502739fd87af66b657d17c98e9bde75b06d92245c293777927f9bb2e105aaca25fa63dd02b63e87f973be8fe1aeafe0bacb189da4a96ed331224f494f7387766f170e36108cc0cb8d653309ac95a270081f5c952e12609cbd960d882481c89a8e9d421165dc9916d8bd4336319d9192b73dac0271ae79f419a0086167d15934df1bb91198971ab3aa66db426261438069a07699a71b2f12fc10bf18941b0e3c6f8918c3201e82949e740f4d4197e1a0e9eaafdb20fc64db52ed5e15db17021c8442418d80cc2284a254d4e3b2ec71ca1ad6a16958cb28309dbafe71a81d8d02f7e13990f6c0bd5f44e82979e7962de6e11276bfbf796d828ab487bcaf093df34ff4c14015ddd12433f60ac9fc2b24aa672c45c0a2765006ce97cfd96a21f901c36e6f7955c8400f7ef225818e4697e0450b2aecc099a953f554c1b2a8e6d0b248b76a842bcc5ea323af8fec19b834dd281cf7a4094eb13eb05b2a51e208e11ecbcf75a4b2cbb69deb431c6547d3943c9abd8f56866ec4c87387c6513885345a257ba899ab07ea7feaf555658737998bf2067c6414d54eea108af4dca729a190e51554efbe989d56f95c5728b30fd27da631d26e8717be109e6e8ac9397fb0ee93f6e54178dc0314ef49c14963c42d8dc6a98c7d1c1edc3955e3833e979f20fa34edef2048d2618e99fd0779a2c29da8b0021a9d8420ab3405f8bd2d64bbe3a523530d9aece260a309cace3ec3ded6f7e78a869b4b73e83dd1504d72a63a5f921b11f9afce1bf02c6dc738f5f420df19092ec8ee8caf7d17f4246eeefe0587f9ce37a140d42d33494dcf09e9d04f2521b723d44353d1ea859fe0d92acee4ede77fcbcb8d31bbf158dc5fb7736ba306f513fec46e535f5232467a7381a12f574d49bf14451bfc9af16a8ed67f3ee04e2fac6a019ffd70255bbdd4882062e73a59cd16c46f06a366a8a4fd170d5aad884479d7fafdc4abec8867d372dcf507f598a1529558664a1b8ac0b45c175a0040c188fe66c543c501a670942b355716a73bdfdb075e2d5c7a87f04d8ad82b88344540555d163860673435728dfc802c44e1758f6ebda4ca63e5ff784d4bc721b75201bf57978daa9ff5c8f4f7b77948c5ddc61e4c2c854f6d8a2b5a9bbdc2e2e7f6053f6e8d8e96c8f6ff567bc42ca7acade3fd0c242949768ea3e3030423b6b8bbdf151a3f518b97a3b8bcc1e426828ea6a8abacb6d5fc000000000000000000000000000811141a2229343e", + "tbs": "a6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f69645820453222b1efc1868d63e35c005b444fae9fbadfd24ee2bd54cc2ff5a58e6197b1677369675f616c67694d4c2d4453412d3837696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f686173685830e38d7dc2aaec0ebd646a10d1b4547579380e37d3de802d84a95f856910544840522b108ef253051803bd7c1f6f29c50d" + } + }, + { + "connect_binding": { + "signature": "914133513407472c0b0c592546b3c5fb5bb3fb2458f11d41b620fd0fb1409cde89998f9f7e13d35eaea0b33043d53a547f5ae2869cedb888d3a88fd60902467b4e642fffcbf75bc0bc3a5a3d415cefcbc3bd9f9abd211d9b01ecc69024b93f448cfdf30e5f8d057e5b0dfaa2e582f03855668bf143ab4416da4aa7d40a81257d4dcaf44d96dedd7e88d02b32e71f3e26dfa241d9307dd8459bb47ba88075d9ca267de748e1bb63825e148afa6e990b1a16cbd2860dac12db95eb20c84b38440be59efd6952ba1da9eaa68c68b96890037431df085e59fdf4dcbd1e77395aff9d24acf136fdef9e4c1e6983de311497c358bd9703335dfa4b0be0875184de6c813764d875bcca4c8fd6bcc444c20ead804e2afe523e5d059890b538c11d8b604711bf124e8a554b3e738a7319431a676997881ae51bf1725d0de1c2742e779d71d6468a9b3cd0d2ed0fc427f34aa2da2940a828b6d281fddc735aa701d40caa8bfae841cbae2f5e9e2d6d1fc6d613af5bfae63c596d5a5a1def42b5896eca41cdaecb4c22f467fa6aa7631e573ce839e61250dd78fb8498e242b73116b145e038472ed224225dce220a12de2b26f55e8712f3f420a451da86b6fa9792b7b9d47ced48a0763c08dcaa30af98a8de3d953b1352900bfcff356b87a5b0b7716d10b788c9724571dc228a3e1f803d4e96a74817019173a02541b13a534aa707be2b590c55d57eb9528faa48e7bf4df5b915f6fbe9cfbef75cf4d3ddb24c15df8427888f0e117b5ca8878eaa481a493488a11e67409127fa2489edc641f1a2a31066a3c70acfd25c365fcae10f1331b012257e961ab4a76151187846db2a3aa25d9b7337dc28aaf287031a6e09b130d6a019537c5880841cd467dfd4a1d08931b1be4e92a25a4177223a653e6fd33d5813594a75dfddf3006a537835d23c5b79beba7c57f582256aecd1c833cd10ee949010bac9051495a4ade1b299286dc2a50806c383782469d4abad4ff630f33fed0af257d8bc1915ae77d2b3472d986824b522a0ac06497d2b819ddd5c55b71ee505bd427976fdad6ceea1fbaaf1a04fc893f6a1e067cc9c522e565702c82ad8447c4fd362b89282deee88ba50081cca5cf45e759ee35e265594b116da7c674c381a6f949215016a3bd90d7bb12313780b9d834435d8fb9e2ef2fe8fd17d12a54acefc004b5f1d138efd058ce6590420b3886f6e5613c5160e6352e5bbcf92e9f41037fb36580a7f409fcf62795ce532dfe8829076a11b9aea1f71af42fd784f7d2fd355cbd3bc2c3c3703f1baa91e530bb884269631253149679329733e2e53cc5bafedf5dc427c44974647b341b8f7db97bf0d5800eee53cc10491353369928c60b14eaae9c0af4041c1aa81f045842c1d17943be97d387cd689976d01ecb066840730bcee0460889604d7b875b50d7b6a8ea951c52ea524265d53401b02424579a6b749fc4bda400379106d59737a6e1a0f9358cb87b436fd41e1031d0013cbeb960eedbcca8f065f54dc2454d30420abaab9bd9978c8054ad302f47af674b33c701918abe6ddd9e71df634aa1faa0d0ba154073670e1ed5d872fc951749634267bac946fa2bd87ffa631c627bc2542ee51e7880a7b06686912a2ae1ee8ecdb76bc76808bb52461bb495d558271dc4ba49591eeb423a43d81846bcc012548719854c2a0b38d7ea993e04e9b2b85f3da953e7eeb126873c09809a0a59ef6738e5c73d220158ddf9c7446ec347177665a12fe7cd562fe4922f639cbf60a741e2f92da391a54acb65cee6300749da41859d55d858e536ff1f4bacb79c46f44373a917ae3c93ddd9f9ed7622faa38eaa09ef46d04e71fdb96dd8f2200a655e8f2378929c2ea64b6c7d5ce043426873f4ccde26a888661c57ff846c0aaea454ea98dbf83fa0e75d2b6d584edc1d6cba06cc466c917e812e46af746f8a0ea9c3308e1e74a9f05bff1961a42a07e61c44d2fdec995bf7cba6a7a5e8d1cc5d623bd009173cabec1eb2bb53df5e8970e0e8ae670e28be60a756dd8b83c09c903f983d8a8b119bb645de999464117ce3741ed3d12e8b46a16ef59e019e27ced68e6a1b9f80d07c72a7d15c47b813e80c93c5ff1b6ac63635eee45688235259cece1f00ed9d39ba06392028e362131ee62bf1bfc849dc6aa204dfa660f1d6f0c5aafe218abf806e9dac1b6fa5c92aa49baf587470a7570b9b5b2d354d6250be6897afe877a34687aff09404f8cd00e1079bee9ad20e56fd1d108362953ac3ce585830949870d9c3b4912b96d1fdb48cde65884cff3a5a015288c90b4bdc17ba9cd4932d3fa44d9a5e8e1ef3ec20d604a2bcbab884e493f37ea4bc2aaef0c74cc15fad29139c1da69edfaa9af2711494bbe3033df5789c751a655cf1da2568cc1a2e613f52a99396827d3bdcbeaa6d102527d90f6398effd7656ff215447169a1c58509bbc018169fe06ed078f8510e30a2f7e94826b542ac71f858554145af13ee51d3e55f181b5eef6de607f305d4b22e9216e9c90b23996013c4c27964d3f037b5e9ecb25bda8a3c3dd821b06370b9c497e615b3f9af29834bbf65c1c22e78fc3cf71f9594c99caefa522a6b171c2f3061ff56128098055a38ecb2355a8f09a88d272bc9aaab87973046b41695827f561658b27304bfdb9789426e601a9e87059eafd8e034cdb5ec1960a66acd9c1c33e47007a061a99438ef50b9910a1c876a225e64bf1d2dd0d11b76043dbf8b8b6df6801781d164387bdd3c1e23ffe796c70a19fafd4f655178b4ecb765dc90a00dbbcc67692fde266fd249fa8d83b44d3f2db315a072cfa23bc1a30bad8afc6ae85f414ee202eed5a4742bbff6a9e0d262faf646d2c8aabfabee229e00fa0e6b0308a4ac3eb483076637c875fcdf1b1f8df901ce7f603d3705ee9ff75eeebd7e8e859f21bdb99d6925c8fbde941eff45f9e74f5ded06dfe0e899b8045e95ec2de3045e1a8f954febfc02799eb66dcb5edb3420e44ee2f1d19399c5c3fc8d11add1194bb666f8030c08d817b91d9359a4091d33ffb46f9d3ba59808633bc41f95b829da6f9e648ffc54d2c1af8f720744aa711e321be159da51f40904fab3433cd288f232fe2c894f0e0b41011af88d77496dbb329b1442c482aab1f9727c0df853f76cb5ac767c086191f532d74fe68980a16fb144c8bd180293fd7e282ef655f6cacd7de8cb81c907508210c00fd91c7047ad18d021be66af97ab684dff468e9f6f127c3388dd103655a28a0dfb33c3b6891cc1f97f17cb1d9d880f7a370a33e7edae50e10c8e09185dc0bc41dcad1552131d5d9a7216a89353a64a857ee33f0ee7ae67ca45846f2b39fdc1520d26ce86831e92bf83b868a017a4a13f873a414f99e709adbf11a21b28a24c71b4e97e8d9615a6a02397be78b2efbf6f03d386a0a26c12f1595cbd455b751e745ac9af52115126654684350a16bea7323534deac49a37333dab2eba3159833bf61126a59b08d1f965590eeb0bdf1f92ed489b9949accf065e489ea5f8015252019bc0ddb7609033ea161a627c41efbe7406f29f49b392798030633afc00467c73a5583fff400f0906af7caa838a7ed5a9f5c7636ed8fd33754224cca311e5e5f6bed4b7f63692f88b557587e0a8fa86e541142399b96923d6a9238427df71e74894641f8a5226a490933837ccec536af3d6803b81e0184af10c02c08075e813c8204ebeaae0e2ad67d646884d81e49ec60d2f42c74e76fd83bcf8fa2e8d9c33e4698dbff9e81a0cb418abea2b29705838b33e1a880840db4c933a29436d21aa4fb77a4d8118694f2a2e87f774dad7218e8e80ca54a86f0ac2bd7336b76042a5565c6e9bda99adddad357458294a59119030576424ec83782ecbe9b98c2753ff8afe2829a20a92b0966ba41ec3236b512fc3e9cd0da1cba57aae80c7fc2cd2ac3eb992fda796399275fe7fd4e9344c0df35e3b5eba598ec7d01f5ae1aa42d3d5af734dcfe6d9bf6bedeb52bc9af76c689621befd5a7afd8969d352b226a637aba8b286325152d223059c7b9687eaf95d078c06fa699614a2be7b8def7319b753ff05342b248775dad2d4c8d8e8331e5d31a21cb5374ad360c0eabbfc9bbfb919eda8c7fd97cf28a75901f59f6d68810fec94da1e12eec1bb5b822af311e56b643077312594d2af15dd39417840d3b44587b13fa7a6bd9592e5f355916e826bdde5eb3395bd30cfd0d9346e2f8c7bc71ebf870e5790d348a217a2eb6d43c1dcb66ef2bca32d49f01f799741f68308977213eee75effb77c8bcc6a9b2ab1485b1506f997ee81ad4056036b3084c6c662bcc8bf6936ceed42222dfac169a14e9c6e8b8792b154af5e1a92541f45f7b47349b69c94db24e09fa75fde37925822bbb5f541967dbcfb7713ff3435e62f4e5fc5a05c5868bdd893ff36c2ee2d03886329842736bfa2af25abf3074c7c735fc989a31947ad0a2f1f0144b5a9a1e3996d50785f4ca41776bc18695162aaf306cd50a975ddb7bdc402eaf36f41d341f74162185c2a8aacb55af9108fea1eeba2dd69b8d446fc1aa1d600343701f9dc40e7e003a797f065abb1036b2424c855f7c1ddbc5ad5d9b66dae1019ef8870e4a85125ff766ca7627de8ef9299ffc5ba6ceefd613dc89244b0154d1ceb143523422e9f3292be03f448df5c4b80582b65f0b09aa1f66f669c6e7f72b7624af1fcc704d02ae9c62b62e1461f550bf893ece9c37df6ab1478889f33a3f726a8830995edb1a102414400e04559c2d972632aca870a785fd521d883f9be0939568d4014541680cffa6b8823b1b9330f9f241e84b422355107c03e6af835a515fc6d8589b9628299f8e1cc4bfa3cdf633d661a50e81414155c2ba1a79d67254b9ebdc78015abab9f99f7db2d57a0228195a80a25fbd217b63a49711c6cb963f3cacfec3c5e4ef38d50f86c5c8946ba18b225d6660e32e9ef60f37c2627eb91accb92d70392e4920b365b2dc8223bda0afc7bc0eee315dd3495289b031f5894e5293c1f887d84f30510cb1b01c3399f69d068ebf9427ac39165bf5780745cdda396276488b55bcf62919f9c635f4d9334b3a20d3f6fc8eb438a3acddfa4512546af197b32397753d441d69c9861f6eb869da76186258712f19b476b068d6d55744c73332b97fd37cc3257740befdd099e799680b3fccf9a6980acd4bd0a09c780dd4ac343efb77630b997c4c87cffc28a54018e225531713327363c2adfc441b8ae424eedd98b7a3b6337eb13eef76fbbc4ce0e0698f1d808583206558bd8a872360364c7c02452551a338d7a861f5840091ed21bafd3df467c8ba25f7a61668a33ce7ec28a783cc2e4042c7268ce13b4a40b325f441fd7b1f746d9158273a6ddb587d585232dbbee02450e955092f6c3c0ffa8270f9b2deca7acf858b2332d62cc3f3041af3ad3bd1022960b57f9118c43e5cdcfc637f19f2291506ca7d007251caf04367b9a51eb3831d025943f962a2257e0486337cf910949498f1aac34a87ca8512fb17b1b00ff0beb91c2416e6bdea0f7dbe1fd7d33452902d393f5e9104f688b5f60a56ea77deeb3d6fff4de9fe782735843e35ce3a3aae6cad3210d09fb77b6ebf2e5cd10bc04546b82b318784273c071eb56f063811539538ae123d79b6986bfbcd18b773b216a96851b2d72634eb920280634c8099b5f9456c512e47f255420d56d9f9e98e37c1d4238e914014836ef5a0dc5810af512983ecadfd556942e4d022bdffe4feac7bb47605fb1709750230da52055684838f2d3e5764dcb538c3bb437328dfba9ce9bc384dd7797682fc3cc26400ecefce06df3c87c50766bb6d2bb826f864dc61b4c77e0fbc0080fad7fec6ad1c425da65397c1e1782b84964dccdd3f936ed58651c63a60896c489502862459c5d6b63f716daef0b57bc82f7e3f93d58d95bc73bd3136ae28a03410e1632f4069e3ae940f0a5029881011c2d22ac36f665d471d333643957c3d3d13490e1b7480c9545e297a4616b3846e8ec733cc95a1d2cfb8abf72578456ddce8aba75a50392e8fe54f32306ebc1cb1dafddb599e7a8023816a5e1b716c3591dbf72cd2250e9bd048bc14c55bb6e8d926332c80ca4ae60c3a83c66e436fdef23e5262bc784294a2142a749ec2cae07d3de0b57542816fb6d4d4b5ba30fe03e0d042e45d8c513efeeb67b89418f22d9373d407286f0529c7f05fdbdab9fedb0083400203fe0640c5926e0f77e015b9a1232babdec426f0baa943d21bd2e68a93f261eaab8f581756380b35d159ce28f93f63d2ac142e69940ebd366d44fdb4751c7b94de7f347a871322059bbab4625b4305176b4e8cb8ea98b66d7f1246685e8e07233a63d8135b25242f3b2181fc7bc1ac8acb230b99cf6088bfe4ba086325b1d8ab0c2156b6742ee6fb4c8ab282181119a0c3dd0c4f74a7bd020535372f5394f92235393c426a8be8f91f202b4471f1f738506a767e9da2def91a2b48ca00000000000000000000000000000000000000000000000000000000050a0e121b222b2fb17a532bfc8c7e6f118e61ac269c177b1f30064bd9d2194404dcd8fb94671b8911f300b16318296a11b8b576849543e31d909956ad2c6bdb009fd52b695be1305802aab3eb3db1777356500da388a74e801ce2bb5f05e162e3baba438b30b7c588043deafdfdf4ebc1a739fa3aec6e10dcb880292719428f09c08c5e9389e4ed67c217b2cfefe54c2e3e3bd5febf7765219d43aa782c4f3853fe263d8a84df4d31bec809b88976148e4251d35fca8396770a4cecae1a9d61f23f07ee180c82893298edde7004d2593f92b56e30cf60133a2b3738fc03eb6717066f159ba7990abfd041646645df6a2b055582c0736dea356ce35774d318eec8fc74f6a249e0bef2404b7f2225a39eb8cc248c375d4c3715a15fa2f73fe79c9e04d0d6ce48f5072b0d807c1c85a8ebce5ab12f4cca7696b5464cc8e328fc67ab5a8f17f65eb6020c0ddd5f973c2346cc4fe12560ad07ebb3ef055cd9e03e5da3909d0b6537e65a90e4e083510912eb0421f8abcc59d966ebaeec31b2a6059e1ba01a7cae7229ed617b4e3d4cfb2056b4ceb5df6c4df5f6d010048ab1557945f1ccbd55fdfd705b545cacc7d4abf496a3543307906d1eda5d8a76e97d728eec11e4099f826cd3e40ae26df1060fc5292c002bcd30748bf86c3b0a19232f2d06b0de6951c4225b4c66391b76a11246db5fce073160814f0ea4017fa86491d2870974728fe9e615a8", + "tbs": "a96375736567636f6e6e656374656c6162656c781c4d4143554c412d50512d42494e44494e472d434f4e4e4543542d5631676e6f64655f69645820cf4966c74356a1435f52bbadda8e5eafee5d4d0300fe103a61e5f36ebbfa5971677369675f616c676f4d4c2d4453412d38372d505333383468686173685f616c67675348412d333834696e6f745f61667465721b000001a0acc226006a62696e64696e675f696450ca670732d6ead8739dbcd046e90a2f2d6a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f686173685830c301c0b4e19dd0d9a1f0ab7337bb163da6b09bbd54b6cf7ba972bef116613f2d56a28fb89cc9eef3b7fe903b5d563797" + }, + "connect_key": "c775b2468df50b8be62e1d32efa25c342a1a4ead18075e395cf17565b72f81e12845b960140145132ee13819f6c36e1d594a673ac412ec7506883a779f42f4952a0639da008a424160c6f87385ee7f166088cb536d2d1269f53cc16b210a40ae1b1720485761d29b04f4310401c64dc74eb58645f8e02f2e7609f8ececf6429f3c3d343fbbd10c60ebc2e07986bc732eb353dec8451b7131c52801dfed033211da9d4a7068d79da9cf2bdf003efd042b996475e698179d891ec6a223d529b25b0dbe6c648623b04eb3fb9aa928f2514a76c997d4f51b36c453f852fb0355914424509189d842355da52631aa6e85190f2167281e8478b4ef99f97a32a8e592f101055a4264a828b80e85d53bdfcc73ac7389b6bb895165f02d45b3ff4e81dc886a5bc85b7e65d09e2ec01083c05ad1573b221d2f7e1a2d4d473fe2d7b0eee1b5bdf7c37f8d1f03ec761ea80cb76b9553fdc791ae732371410aeef520b282b167ade7997422e6fc83363954a850baa18a93c59a7227a1c302974f9f149de4760b3f3df604330ef4d244f4713a7e2013ac103ad0afeeb922507987858e32ed48fe95978d18b9b33c125a0d9b68c8fa635fe705958cded87edcc23bf139757354ae5e95b11c0e6b680f680ece6a36ac8e51ed1ae335e0b9e79dc461cba4fc1478e4083ed2a64f2bf4c8d594f21902993ed6b0eab895366480d4fe7f41f5c6ae045250a48ed8b0c92d025448b4e89f3f6e2885cc3b3371f8f4c42bab382afe53277e9a926299b56f097f78d924f4568420b50965aebbd36b820eeafc69d471c2ecef2600e7f53a42b760e8bc3a7f3de8e58511261ba6261deb47a9f79a877110f6b0bde866391064facee597b4b8a5cf81ee81904f85c0c28003d9cf1632d40528b543c042d7cf95c97df6fbfa6be828cc8dfca03832f5586b6de211da513e31cd4d35eeb408c636b76067d23ece87cba376e39c604676720870a390450dfaab7a9f74a190a080065ce2dbcedb44c76fb1d47013e0d854e72d9fb69bc7bde1701f0c2f636f04d8e06f4c75c364aaa99eaa6a8cb64fa064cfcbd03154c12ad3182e1b5fbc2a6e0f9ef235612081643a504edc600301b237e48b323b7a0ec2d608b61f62dc7bc1d60a850e3f9f8f77f989c1f0ca3cd1f899fa2863f3b6fe46c4bd4ec0f156464150fc10d8acd64b38e391f0338d757b35d84b7b45bf4ff65c4e079f88c7028b30453df0af6616ed7f0dfaa454a086949a539443261f56ec15d4c014184543ddcf69fd52b013aef21fdd11c75b55d027e0ac4f5875b1f93b99eaf6878b81453b7679827d1a2881286d4f86cec7a25611b10cb63ff65a2ad5cc3bac0b7dbf9088fdd15e8b168e85082c96c289406f12e0300da99d94919f40b357d53392e38eb0e00bfcc531be1526909b7ca0d4ccccb35475c9878c90d3da3250dce2b6d27471606e021eb4164a3186ff459fb4bffc915c01f7ce1a120aa75d0a97d3e219fa151cb4672931af316bdffd6ecd7112efc693fa85aaba38378ca58797d74a21500673c3e2cd5dc931a8be708f430454a3f79b397662c6470ff1ed743d5352d9730852fd5101103012e597ac9f172921a0c116424eae28c302d264c5f2247a12b7f727abcaa08dba7b4865ae80b09469fa35123477b1289857dcf011404b80958c2329f2584721a1fee565279c4468c6e6c7592600533f859be48df55c8a1caa86fd744dacd17604b724af77380126a8c14d1e9b36eaa23b03a89be3c438b6890667d6aa65ccd80f8dbc0f51c9c6f094eb34eb23676650bf37f99f0abf62fd1a2c91d6b021c956c86b224635a701bfaf9ce1e82e20bfdab015b5e8ca7bd2d488d0616798a7ff7a6fa099a2d1e703c6e1069611f9132d94ad624d737199a755b8fbee67a748b3162a7db03cec1efc99c53fe55d7158a4540436927edf1c490df73a4b05836ca72c04d6dfa0564067f0db9f02409d470a428a9df3dae8884c5759f5b988c066b98eed887fa37e77c121892f5830540c22518e04c4a38611110bc5c03a7d814268989166c06e1a129fc3fbdf3f5a2faf64b5917087ed04376f4f6ad2a9bbc2fc068a28339fba4247b9461ebd0ed6784edf317b86833cace8aee091b03d9ef40914417a44e5a3ee4f511505edcd74c8cf2b6540ed26748f214e609cb173b1a977f3fc6781b9d4feb7c5a8fdf3c8488dca6ea84e1b322ec72ba76dcc360cb26b4d9faaeb5dd469104ac50384048b0ae5c6e38fd7c310bf68846dd816d56b74cacbacaef251fe45d741c1c03267f3ba1b703dd7288cd0b1032e2ba225060b191585148aa55b789e098331d38e31fb42cb953e3d1addb6a813aec9cd2213aa0a5483fa9a7fb9791c4f986d93d68b810158b451c2391f6c78a7bd18ff195e7911ad45a6634be75e6dcbbed9f4a36b6c9a1ebe4b944f0401090511bb2ef3e81a1c7263336d512043ff87363caf748a6aa0f513066f58cd85a05d4435496f5cd2d3d19b8f8c532bca28f95aeff6040d4c7d7dfce1e6b746ff2a7f4f6a96fdee6bbb24234f8ba67c0418d38af235a41eaaba52d13ef43b9514e52469d9b2407e87aecaa640cd621b820d050b206e9c7bcaf36b1760db126fde6451e4467cd4fe6066ad8ff8d24596cdd3105e9ec195c4f9a663e4369b94501f8e9ecfbd1925f43cbed5807207d711b8dffd205ac0610de782d6fd3876b14fb9aec352e0c668e1729e97ed679bf73a91ebf47a46301afbaefd8946791dd6525022046a6f98fe23c609d5e1cbbaf32e08842042b71a7c5670eb7711af5680401a8c273a414903b82c351e160626c205d438ba9d284c627b6a7a4364b833dd4e1cb9914c3061633c11bc26caefb89ae79152b0f983e703b3770df651611a57b63928072553e2a07f17b2b63ddb916e887ad56c79b3d8334fb5fdb162f9bde5a9409117f5f5a02b2168c47d67c4ea22478fc24586036f001883fb9709ccec7e49674e1f148d237644b0f61f4c54964fb056b625c567f030ca6eba27c0969c1e31f78bbe2ca7bf83103dbcacd398d559616ce70b9f42f3dbd54ce48a9b74c5ebf47485b21d1658119310953f709f2b5606d7391737c6732bd0cc4b35f58ed83423d2f294fad8d70d6663b15d3ad7a5e5fd76cec055efe6916e49c95586e9fe8ffa5850a7d231b6753b4c37835187131ee8fd8429cb34ae00be95b4e61461aae246942e0bc3cfd83586f4b1c0875f47ce1b8ffdca0f1c3050c87094df3032d0d90757f5be91bc69d6393413796a3a9459ff54e581a61309f85db5ddca5c0486f22216e7765d513289a4afa96d9db3c8fab071f7c89ef8ed600e5e4a2f636f8f45ba67ab65aa2f9c8a4f808e0354a5b7934355471057e404b5a1f1d71707bd1931078353097101e08c6bbb7da012bceed7ded703db6dbaee17c60a05c19279877d2a76c9508ff0c359c43e4e275c372644f7b80798729f8d73633b0ceacc1b52b35ee521713b40efae5c1e5052a15b82494e0247784c39854d37d6d2caa251c189e229db8e573abd35c56b35099a0eac2889411f77b1105b2e1df22fe111a3e4d984cd5480d0664a72f1eecba4ac69ce2506831f7363638a8163f14d344ff25d5b50844151b5ca7ba76bd89f576c6f9f5a19ef69335c590be33c43b5046202c7f9e61b57ba73c0a93082020a0282020100afee1ab24b2ef29acc141fd85fabd0fd325ddc698652d6ce135cf55ae156b8e7170f2aa0552b10fa5f6960d28824365c3512974cf4fe99dccabd26b5071cbc634244189e73cf9a9210286bf0c1a10f343c1318ff0790d92085e22771b7cca2139d5ba8e3d2ad0a3250d747b77dfdb85cce19b27db0d4aa041400095c6daa1ce4371b6ea98038cde46ab97f72cd2e64ebd79e922ef738548cf5e7fcbdf6e279d600ad9f34298e2d7ac42e4fccf36631fb689cff2dfaf41f0db98456ff4deaef6d95a6ee1af808abe074c2b3d9371b09c974763bb9cee6ecb5d3f539b4aee17681385b93438e47590c5bfc2a086f38de16714576dee9b09cf462f6641eeecafc52dc1333e3fc704dc1d888bdcec4ca18ac325bf3c0069b894eb6e3d3b517d8533a87e9d06aebd378652d1cf5015afefa6ce61e13913347cea82e028458b1c91d2917c9d3707210e829d77cfb1a9868a86d7ca3c2cd389c7cb85795f88dd3464bf7bfd33410102673d2651b087cd8d72c17a52cc20f3e4f09d37ab58dde0df4036909cba83a9d4a68666df52f8e0af82a92bc31350a908bac80964e917b89ce2d72eb7daf81400f74f13573b20f63ca00b30e88e0ccfb2746699f7200d99812f45f90b3e0bca247c437af23ce45ae8b4912d4fe42a6c0a76fcba57295ff919f9b93dc7065e7d74f90e11ce7d5b504ac3c35296ee4c9ab0710311d74a8319b581f750203010001", + "connect_status": { + "signature": "25eb56997638ef719a03dce258fb76709f2aa184470785320aa1d3b8b6833948a7acf9bca50f8c920a880165478b2b3d4e3162ed7a74a8aa4535b636e8b3c6cff274d49274fecfd2091bd3917faae304b48680f34aec37d0ecbae47f4dc80a08e27d9e899bad9e462e8ccd3c2318648823bcd9bda353761321f37153d986f5683d20c2f33794ddbbe97fc34277866380ac5ce175a66d761a4324d514780bc6d31a8102e1df9d2a4de4981b2c6475812b5bf728196dee27844bd4de3d3b4048a7bc4003dfab59be471b6d474c57db6b2cfd41c96cdadbb333208550509b8049811ad961570ddd0c1b8bf73b3980f76b9a1ad1613b62a581470fdaf39c39a26ef1dbccdaf07914982dc7f4e97d589ad8593dcde0e50a37b9438c70e47e50d971750510fbca90847ecdc450173c38767a6fe03a6386196a9d21acba7b3bac31b75f1329f6d1bfec64024342793ab7ca85a078565c808abc0041cc63aad8e0fda969a447855c6ddfc73afcf1e331b4229bc6fa36e8a89a90bb9bc4b728e2a1ff6f19c7e8453478ddfddb372b219024eb4bca0a57b9069bb847adfea33c929b243e91756dff27197013b1cabcd1c90a2a32ed42f1c94d1db19527515980211d64ccb0f5e14f50076aab304ad3f0d117e65b35affb6d42e7ba27d7f24714cb881d022b035ebc185915a4f69bdcc55dd3869022229992a84f7633c020f6ab9981a9d7ac2f5c458ae3d92c6bad453362f59735e25dd93fdc40a18d305f2d7808ec999b6382bf502119b0add58d4bb3e76355f08d8c64c6f76bf3dd79285e463baa80fe02e1d3ff22bd4470cc8b7fea6b8777236393b586d57bb92059efd61d381022ecbd8f4b49830caae86ae0110dee937d30e065d21760ff92d5e2dc19cd0d5ef1976cb3c34a813f85bac05e3c2dda8d92e0dd81bf14a2c85232d8d69ccf57f2c1850254939fafa5c63da6a0c8980fbb32b9c5a46ad8312058f9b28fbd3e7fced7bb8d70ab6f1b73de69fb002bd6f260a37ad94fbd9be418d1978ddaef8e6e04b34dc236527868996221aa814ecd4d8396b438db9e4e7d81a2875bddcffe23be82f1414813f424b1fcbf350949b7aaae1b91ab8f4d6435c0350a361cb74cfd24d49beffa2d52f07c79ddb4a6909db40f5c4ac1653b1d8946d8d14ae025a1c3bd0151dcb4f678c7df1d8a36d69878177e0da4ed1bd9a877d7c150c53e6b27d78c235088146a43ca948737236b5f586de49a7998b9b8fec8a6a9b470c647d7039396156b908d4ea31a6d741488646f000b5425975fd7c9a5b65f5411c07544ed9d32035fa6f64538a3614d1bef16eada74371ddad631ad8e00bed48d811e6ecce6a2565054d8bc37989c5e60c489aab1ba831c85dfe5f60f1ed533b0bd1edf158667a03ed21c26c634af85eb6d3c2ece855788dd3091cd90796dbf0cfe889aaa132f82354eb4fb3cd2f46e5ceb18bd00a6831ec84dfceb1e80de1ea7051c3fca073eda127fe4f8e91d08b737a376dc7d58e91c7f1d5b411373c998e19bf359566e0b19f0ab16b80c91d581f8ad8acb46d2020f721ee8b7a755b6dc03c6e257eb53bed14acea87425ffd5bad0779b019cce65f72f72ae16e1a85f625dc21f9996baee91fc339f8fea13b85675ccb07af086f712313d909ebec23e320ead5383da6c329404476b32862a36fcbb5c74d5852c849992ace472f9f21d96a8b1b5d7b23cc6172b04a049aae9b30a8aef1524ddbaaf38efabb7ddd757aa6726f1589eccd626edcb6e3971aea6d0fd9ef26aa4406b45f7b0930ee22969c6fee487c7b31a414ea49f4cfe8b82cc96787dc6921969413d47e0c7df174dc7e3f68942863f388f573d580e4ef46e77c2592eacaa8021299cef85f5f67a20aeb9e03d912d37544a52afb079e9e9090377136a17eaf0229f5e7d5975e90aa21d10840a148ffe9e7f5aa1deabfb5697c1e7e1630883fbe62955649cd5d833c3662cd0615596356948e69695ac665eaf4c03a03cbf7f4da363b69bb36ed7d6e26fe082048c1f13a1758e9bc017c87b70db86b966cd487caa92b3537f72b6a79605523783d40609e1bc5b5c1f59da179d0cfed434e0f4d851b9bc4f7cdc9e4951d344703e3f02d3474f5a5bc1bca4757f131895f9a3689f6e6e10c86fc5c040cce12829ce78fe20235e81139e1e6dcd1d7e12574c7fd34e0e0fce60d8a15b27486832e4a5a51cac715cf7aa1ff095814efafd3578fbe41f92825dd59b4a03d9889152d5b10a9b91eed5a9d523f2e5f011dfaff9819f569f11d8e16023e58b600a1c0867026032397436f172021ebe0caa3c42b927f5844daf6ed0bf98e07898f30bc84ccc7bfa19331c981bda68578d04842e5f5d92f357539b63a9d674fa6d8862990f261fd905193a2414b4f2c5e6affcd464ba834345d774875b070592a887f7cc17351d7183a8a6a6502630ec5db5f9aecc52c99fe81c5e420d930709b67416650bd7b12312d098112bc05b028e7d5988b92727c2b67efa5fca22578694c6ff4941bd30034517c14c5975c6c97aa4814bc0cfe5fd5ff212e80b6d02f7ff59c8a9f55721f0ba5d9ed96ffd122b336f751976d149a7d3a153d9eaf3fd2f7eedb83d403247dcd4a57d1ddabc9991dd89bdf93f934f7294b1a5aee59e647d1ecc6b560a4a096e14fe6690b9cecbdfd19b05397374465158584b66512e2a74e72e0dbade643da3b1809e9f56ac9b31b4cda95c6c8804476db18c29269096308070d880b7eb3ff403f44a515a9003fac8b655144d44c3eabf97ad359308826b65d4951a9fec7bc3e7e067b4a6333610bc3b4b979449185854830ad3c00390d050d86f68cdf369e6fe7a684d467eae0d6e5a86372f99a21bb6bbc9e05c54a21b072a13746e3bc7225a8ea9c3c795f9d7fb9d8e73dc09eaced9c615d8789126cc3ca562121d5d988a62dbf279b83d47af7464c280add3e4474adaa23effe6c75f3910d94ddda61e347ea7b289f80c09873c80c4ce5a37ef31abad3fe81d20ee2779a174c9123ad976bf6e9b5ebac0f23fc17e3af0c0797db86d616fb46193d7c380c79a947e59848f4f03a801f8706513655e1eff9bec928b2128e56c48756708b93f2cedb3badd2395d83e76d9dca15a5a29357986ad468688b6db1f95d62078c404ae76d610ce0517aaa8d22d1877397d76ffc017ff5fd0874793b0ff6a5f7c166c10c1687a4a005685fbc89d3cb0d19167ebe73c508019dc2ac659dd7fd43f1cf55d73b318dd247dc86573fd0ba16a917b85c498dc64fb8d37d581039db471671146e12f7717ec9a5229354fa4e3d4330d4ee86b39ac4ea8a69cb3dbbf9b3ca31b0224d77248a254bda5f012edf3697c3e2a66dfb9f016faa6f167ad891337dfd0ced302ef1e2cc643a70869d6fbe3b7a215e5dfb01acfa20f2f06c4042ad9fbbc77ee3adaee75281ec5a3ba8e3d0d4fce9914919c1ce804b548f4e478cc48dbc987ccf8c542511fe5cb67454047b8e96c45839b4afcd372bcbcfe9aa2d340513ac3fe23b8428c550c0c65cf82a47ba000f01cab4c820173dda7d9374be8cb16040fb693fe7e9e46d123eceef0d47e3f36f01f9a815368d335d9b0114dee7ecfb031df8eaedcfa7078defcdfa62dc4121b0cc115a016800f3826a2596412107dfbd32da1f024daa253fa68d9019b81e71b028c6425d36e57271ad798cd49b8a4a49eacc5d86f46a585b70b59a54af65a2fc0bcd77de0fcb6d7b6c6f51bb0b52a24e7083fc06e63172b3310453b70db21da146f646855393c9812bfe1e0fa6a4a1f37c6aa624cd126cf20ae1fbcd52401aa6a56a66a200bb4c7a184835f4c7b08d5953ac29564cca5610e62d25427bd61cf678ba924bc7a3d1dd76ae0a20680815facd22538e8830690c6a03f554c0b5663a063124f7013b8e0691a208ea8c61ff4d7e14f23829430aa27032c47d7d5c67d958431c14e1267154b77f758e4dd939eea03d601f152544f2303b7ed865dcc7fb8e3d73805a5f171cc2ca77f3292c0432d9e6a264233d85a66e5fe460c055841ac942fc0564095d22cc9a37f76c3650733de9a8046424c15d57773170ce945e08934324d4ea1509e1f0bee5ccd871001a660c5ff761b2ec9cf6ed3e957d20b01339ed6c0788c953045a2eb90ae637c8d7b160d6bba021088e8b2c100eb5067ba5c21a6ffe518e62c0b40d466bf050722b767a3e3ea3ffa923dd685c11aaaee61dea671a2503ea8009a8081e5c829436571bd6fdc866a3b10378a7c0ddda418c4f728e8337eeab86a6c59c15fdd750ae3f0bc362962de279bc8e9beab078388fec58476188450f373056500fa27fc30220389787849d4afc1b0301d480b6de821ec83ebe725ac862459f16dfb56dcdef2c4910dae05833afccea9259f7d27cce386c5c94112e954450a09fbcbdd3025394361a701a8976ff7a637e3c9be184619b7f3972b50db58741d7a9291785f579b48847a8d369cea47683198dffdc32277243094bd57df8e9d9e37914ef5cd3536eef4744e06efc8ddbb3d86a143c3368fa34941baab65a831dde578b57cefad8e8e9133ce0c0925daa82aed230444bf93e3eb893117870bfbe8eaa17d2d8e920cae482abb5bc986b11156ef0662392897bb67c2055d2a6f83fd824ecb33ecb08111a29cd638d19fecdbd6b5b12fd5a1838762a8e47b37f28988de355420185d9e40f779d6e4f347f220c5260869962ec69757aa32575b8df33976196cebbcae5aa8776c8fa53e2dfb4cd804149bdf6cddd11d4e96897ec2d3dae26339dfd63b8f3ea2c90f127d1fa6640043ca40aecc1b21c9c173186ea4b381d0bfc25178a5a4d5070fd598786e355b38d7d388babb37b627b3fe246e1dcf85ce072e16bed87def264a3a718b7276b883002cee5889ac4053a16981b203cf13c62801e041bed77cab8a21ef038c137d22039aab101b7222c4bc04e22188036f491e6e77aad2850f9fe2b5f5a3d2dfb48925c3497508ce779dcf668d6b2b291d1bfdb55813d60b4857bff16ae093ff486653e4e87bdeb60c3567c8118caf07122d91d7dd5290687feb32be215f5d5fe3431f1900d0e8ae7d60877d7c95fdd93f50b58abe3852dac1b26d26ecc238c058a18c4bf61975d85804c7d3fa0a8300aa762d98c9c71d857c914098b35fe639835c134bed9366f17871f609cfb106072acbd22ddca7a4aea8df6fc3d77b8e14b8715bd504f74e050c7eb7bb595e60e6fdfda03594b591a46a86b840d4809062fa125f830d0600368973c6889e3e4c85a61d8d0ed696d53daafc03eff74d777f1575414be9ff383506d163ce55cf23aafbc2885d657aa345526670d4a152d7ac14323f9ad9cc457b33bf7285b53b3917e30529b5ce2fce13ec801dfa97850d3753af463c9f734403d98d5c11ff2a67f6a5e95937322551151864989a872427227e10abe74832f3fd10cc70ecaffb602b754dc1f106cb66828c9d1ae8771ecdee91831c624ea7cbcbd58115e2017e0c318b22efdc7d728c8aacd68bd95ce5428cdc2ceec8d53c6167290dbe157e25a14811b4612c5700898c3b1c56730a067d9777f843133e499f1636c9db5700864b24fd511cc03b0a45edeb3147c4bc338952459da91b53b31bd202b8940721cebab83839abfbcd15ea13fa5ea9296a802aa5edd2e01db5ee0f4f8337b18c0eca5d135c898f90c030db86355e56a30bee23259470b965396b5ead4f8802b0cd04c8362b0c7d5e7a36bbf5ae4c102cf8406cdbeedbf0f2c1333d5e75f50a80c9f6912e0fc621826baded32e985769cfb7ce56671adca569d9b8b5d346fa406dada67658707241c2a59a61872bc974782817703b292b6cec4048052e908bebe263e5f817f6a12044504d323ca4d062e2e02338befba6c93e8f3713fb55b9c85586eee5dee4c7022b1fad0722e197741f183cb6ca03350f7aa32d44526f2aae614ece507e294e6d422482783b91c2ced821e9d7773374fde2ecb693873ae9f91acaa0cc7228f94d6ec103d4031d0b09ab03a96a887ddc45cbabdc32731cab85de8299a455e181c3289333ac33b702d4fac19dc7119b85f2dca93a0abaf4c38bef1221e0cf0eb076ab5ef872e9f626d70e9f7244c14609853520764508d3988ed2098007c1857635ef1b89b5d756a8b8cdcf80f1cbf997a31de151b759a4f5258930d71a8b7ee59527087e4045d46fed9df5270e61396c5f7c2f82567a7237bcba8902a17c877512061ce8dbbd8f865dd3ffe7b1efb07cf658fc931113a4beea54cc87acd6813a736a2144ccde6bce300099acf5f74fcd4c34370c86253daff760e1d2e9972ad1c825de2e9e124cf074692ec2c978f346ae09796a9cf26727cc06d47cc72a44286f880f40fd3efbb307f0767670eb5ecaf0899b8e868d2eafbc8557234fa9071c5e6f50cabf7c045e909cd9ad6e1d0ae40f74162a4bff6f8042a5c727c8e9095d6eff387eff816699db4b5c7dfebfe509aafb7c6cf010d373d5356a4b0e1f5145f606c99baf1f3fdbcbec2c9d7000000000000000000000000000000000611141d232d363b50042064a4cb6ec3ddb6a9f26af209f8b65df928ad9f988e7d1ce5c61aa09829586b5cf0f6c03d6d8604f2585eb6d622f79933ad589c261aff2b87b1ff70a012d3de9dc06028da83ef53b46d21504d3144c458b424ea890362437d94519ce689ef45b01e67ca640d7f3b2216dc5b7442720aa69fb4aa7e57084397df769c4e41895bada62891fb480b3ac80922e01f19f1d468514d4689d32a8965dcfab350ac46472b65f6186fcaa4411afbd1ffa1cae5b50f12a07cf29463e0455ed3e5aa082f035cf49e4a48544e6c2337e0d9ee23b78d39a8ba7dd62c54d4edafe578adb28970748283d1d665431ae32f911006b2c7967d5fd89e398ffa57518dc4a2db1a0b10cc9191c2feb84e805fce58bfdc752b620404278b28a52b027cc11b1e6c4e783550cceaefac78db34b19b3862f2d41c82875888112630c5e030ccd9473cee330e6e3fc1a4b925b969bc888efad2ef456f2baccde7fd553317401964e8903de6bd1788aa56fafb458f69dba1d841acf2af19a0bcac674fbbf7212de438c8987663587c7809280ca9d58ac7fe6ad3c8d2ba3213ec6e4533381689d3221e881853224bfd01b8f327d73f86406c081cef24b8ef66595b80d352d721aa269294978902e59b7c4afe1820567da0db5744d42db5e7f2aeca020644aceccddd7939699d0a6bf3bed3202f6a398c937c98ba82376f8765355f8d83619249a12e593d54", + "tbs": "a6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f69645820cf4966c74356a1435f52bbadda8e5eafee5d4d0300fe103a61e5f36ebbfa5971677369675f616c676f4d4c2d4453412d38372d5053333834696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f68617368583047e217d4834170c987264d024a129cb4cdd935f1f4727678bf69ef88cccd98006488b948306269fe5c8433ba568ccf30" + }, + "identity_key": "e831245cbc58a8f019fd6313e94c1737c4eecf055cea9c3370e6b60873a422ba516f742de06d5ef2af62ead2c847ff2d044212532e301583256b9265f007c6faf74097dad7bd755f1a159a83870148df1c633f016508c1776b949039b09f08e26aedd528b9f37e41da91f513d5c707db3fe5494894ebab4047e45491da0b98ba7cb7227fd12dd8a808c0c4ca99566d6ac06dab350533c7c513b1519b305acf1afe82c896a81a2361eb1fc778530438960cf428cb16b9b9004ccc5f7ae76beedce396c3b6a5513a52ae7b2679a3f8d8856746ee1a5c9f2888cc193d45739a0b921675ed4a6e9f7fb7d07e9d9cbc87e8a8de8a93112351e810d7e4121b53a376a238e78263499bae00fee6e059cd4fd22bdb08dcbd8dea1197675cc80830a42b3eb0679e828747f971de0a3b006ec1ad3de8686db1faba077faf77565c8b3aa6069c391163bfed911dabaaafc3d084a87540c666b47a5370035f071199b4e8946c358d1de6aaf79ba4ce7a69d9e684f57df0e9118eb7cfc69c54b791b574f71e631eadae9ce5e90635f0078da7f0a49c2a59241cc2ad26f37eec7247871929e87434b64dfdf6206b62f2ea90acfc78121001e350859c51aa1b0cdb83c3872f7b63becb7d63140bb12903d015b07acd82ade75944d72c793ac83aedb8878160c6eda41c33838f7553b29e05156f459a2cb7edc54e63fa52219c03604474b433163fc50c43c1f2a0712095d4579dd8a1d67625fa9d795b1dcc986c9bb896a01158c4bb8c980a081ca2fda78120c7446796cbac9d66e8ba1661304a9634a5876d114d95c2461ddf0cfabdfa31d7745ae73101e530cb38def123283c8d737fc61506ca75f7ef2ee7098ec1bdd8004f0545e333f499d8089d4bd1dffc83e0886551d78eb7c31ea37c3c37508ddf5a166ed0cac3f9ba9833c633dddc20910a5a23e44e33e82ae4fab627613565cc550e6b8bd65d0578df5c8c8841759d2daa89fb1a6911ef12a5a48d1a048e5b7f9c211d522840c924ecf900c8ae1ca946354ed8099cf9696eceff933da87b3f901c10facd2e40f82f76275bd9b512c2500e74adb80643caffb3572323e9a2b1c1b8a2759efd3d5aab342b606bb75b78f37788005108e1cd62105072dc5dd26fe1b9d454dab9f116c545e44656eff52a83c0c9f822bc170ce91a9f6ec96a04b4f58f3b20fd72c9f8697992146b0b554a2116d6c40b3e91d769e052e7f62639683972ca915985070e429c6c4811d94742efea75088b6169376efc11bb5c75f75e3edb9fc8c45e4f2e7046866ffbf02a0f82e9afa6b65a731d3e041819594c4b77ec4644d681c60f07b1867508cb50ef8316173d662e85af27cece2d190ed83c7ac57eadd1efaf0d409d340423a59646ff52ae1dd19fa6247b1eeb29cc397bcd4a79a61bd754426aa23ac8f4915d0753d5cb5dabda30b8feabe4a28849928047c30c1a0ed56f85d34599e41d95edcc5cb15a8b983f4ac60bddb346a26c5bb291ce174847681a3502da9508226cc44e82bc6bd439d4f7af8697e92172315bd7e15c1a01e9b1b77e4654085422163d42133776c5d85a7870ec5413c66376eee0bc36c3c1d17622e2a4349e1cdd2b7fc8a64ca8d1614377cfd2b938797ca4e295dc5cb0afd531cc79f96e3a0b641bfbf01b6d4e958ba048c8c117039e63aecc8dc513209f56011e1ce59f93043ff4181030f083838021d58821429d2902dfa50d8c3a456c7acd3d0f700286c4cb17926a219ad04b8ae6415971e30c879031c6c24563385a80964b20986bf121e4f589423d04a7b16e2565ebace96492fbc9f683f3254f5bf4f39963ef97564e39195bade6d36ca77161022837b544864eb156147a203ad5479f69dc2355840deeed4d9d82ed0963e80f358b925dc841a6e555a614d997adc5eefb553343fd48082613758bd4c2ca9543e82b4eb46f47631dcf5f076f5685d6073580d46b275f4a3543b2d177c99e64fa1b3287525eb190fae685e8dce3c05d097f6e80990b292b1cb0ea22af8c5d1a16730ae8981e6931abea605a384a236698667e834b12ce274309d247bcab474e1a23d1cb238f631ecc8b9ca44b09cc293e2d3216a808ccc8a4c02666cafb1412fb41efad522d2369712a05eabdb5d04e60894ba74385b26c99df3cf3f6d7353264bb9f4546128141728c24d12a9a8c71dc11f485da8f577ef34ead114439ce2d24bcda25dbd983dc544b9e590eb90fe5af2608c151f448e0625aa8574981a861ddf96687c11a3b900e6b3d01ef141677378922a1a8dd3ad87f425337cceb4a1533d1e71b859c0372249afe8a5e91a990dc207b16efa295bca7e1e3e91f0b22c72ecb2d8555441040072a539cc36f37ad9fbb1ab1b6bb4736d1536eaf70c5c58baca371035ed409bdb72fcae1d0f2f12190a379eb24e2ff2713b7c99ff84454059d3ea8b9aef8acf9094da1b36158c609cfcf7e335bb0265edb303beb86e4e1750e35ee4d67819c2fbd02a1460174306c836eb5edd888835763ccb3997586c1a4f142ffdd1624d400ba50713bf5453b4510a80528a467e31dafa67e7697e3712b6a14e0c805545bce4192ecf158dbfb6256fe6d62cbd17a0e8bd81c3bf19f3f50f47a886e4241bbb7cd73389bb7f0c67af861b97267cd65423d58fdc848d907e4e52a72a5a198efb635412d8a4ce7fc9fef68969c854d9ce0dc8aa7e20bf358d28ea2bac599d772d4127bbc2ba1018ba66c8ce95e65606c9ca28b4824ebdc455aa2ad1cc242d0f01549419b0b7e37747d1dc583081f0db3cacc4f12f783d36997c8e59cd0827509a57fd766d8e8135ef6c09f773065db8a1be1493e8b0a62c7e70dad0151cd0c4b6e599a5216c0b7aa6130d2209f47f2868264ad5f64efa3744af348a508fdc5457f808b3b3fe5bbb8905a4305fc46ee1ec73686a8d38e91e25b17680f4620af1d44c3fdbbc93b36cef0fae20074167a23b95dab03cf8bf959e46fb8df6166ddb854852fd778251d138bd27b34d2eea98f14a56611de9c57beed74385f1178d169bd783649fa80839832ab17801572635f88e76bd057f75d67bdd13be16cacabae649f54187596f45b09a975bdf347b2dfdbd06d5dd1f686d03238106821d91d06afda687ac886ae568c2a52b91a19b65d75524daa718ff32cb942a221cf443d36cb52c6dd5bf8add9587868d074fdb931ecdcf5d4e39023d82fc021b44b99371b62017bdf1ad958811f9bc280c985ab9fc08a0d9a665b7a483b1692faa76e2390a2f36f90a86607176ac9f56d6b43a8b846479d52b39485cb1c45e30d8c352d0dcdd52e790a4d28e4b0f38026fdc6375362b2033efb2249c8b6d213f633ee76c5835089238f45b1c5cfcac49156bd3699da9164317adb90d514804510094c9bff00f4ec4b2469de6f9b6dac923bb076a0a2de078bce3e942a3c3bdf6b0c6c3e1ce936c960855c6214929d276e2ef93ae909adc9472894a082f324aafe738725ceeeb514edcab833c63984358801b558ff0a10a2cdce486f7e3bfe73c8dbbea90b272b9b3152301af8ab877e4e99e0e96ce8de6783696ad79dbca9209124fc3bccad51501bec2628a0b1bfc4b387e34b23f8c704c98ee6d0c8bf511030743433d0696351a31cc82007619522ea7a844fc7e5aa893fd878351186fc3921983082020a0282020100c4cdb3683fe2b6996745940416c38dfcbf16585b532d40fa072f6b31c8d3795751d13270ac4efa8f3ea3b909313dc6f8167b0aff011424fa9b4ede5b8664baf9c5eafb2771c8a1b074fa7f14905710aaeca67c94d896d7220bce734e17e861b6465059672b013bc63dab4f8155cb6e5b5620ecd3d48d56c0aa144b5ded3ee8475cd30f87a5ae4b9683b4b86ee9a3ab54e8238be040726130eecebe30454260c603ed8f84b74a449bd6c55f651088b14bebf86d4a012b631229b290d601bd688203d13755204e3d503f0fb555a238b31815c0f2f78188c9eb27c8926a38b30a73d32345619ca802f1745824801e3bb76bc2f8c17087dca8452b037c1644432c02a1ab9747cb5c02a5926736802814e124222e637bef2fd951b1b2bce9c1850f3cdf65a9e241d101c990b97c7af84d1055ac6f3233bccc298132886af8a7e7671b5fcddbf20d06dd6c7bda59144d9fcf6a8536935f3579f0b8949aedeb1b4dbc6e24681dfd846f88a45ad93b93ef93126d3ca23b422ca33880893eadeee4ae4e5355e31e9ab7cd819c632e405d7c66fa5a8e43c0248dc2954df059388e132630cd3993f9f0829d763a49c9c5603e880c6e70f2a69a01f630f60a938d7bf206658646b0fbadc446885ab0efb294ae53dc326c9d577d66da20f63e45b8f98a2fccf5da5868bca563943d3ebbb23e8da36de9654b80e6d7c90e560aeb2b6577ebf8eb0203010001", + "leaf": "61206c6561662063657274696669636174652c20617320697473206c697374656e65722070726573656e7473206974", + "node_id": "cf4966c74356a1435f52bbadda8e5eafee5d4d0300fe103a61e5f36ebbfa5971", + "now_ms": 1789000000000, + "profile": "pq_hybrid", + "tls_binding": { + "signature": "6c61199f55cc160b6c92635dac23ed7ee3cb3b5ea743e947a14ef36b3ce70dfc6e6426087e0dd1921aa6d18f153e18c678bc4a583d750de7e654682741a1cf4f1a365eda7ede1abcd6af293c3f42ad98a07f761c15ccfc2388880c70832f698484cdff31d049e198d690522086eaf8fb342df715b13e3a2a7ad9510c262b384a138f75602484542dcb3ee3398d15b6140b050583188ff92a9b2d0fafc195e961aec2f529a848a6998f8560a1b3d6675efa6524807d0e6ff3200bfbdffc65fc7711df2410127633c11676d14b776beda6fb4e9868abe73bdd91d4a20cc4f6f7269e81919ab2523bf00822de1d3747aa68a42e6b235f99be9bfddd74327f7d19c886fe02998f53817812a0196323b0d1deeccd5626d573cf50a5d9e869ca3ab6f797ffed36a91b1663c3476f94bbd8ef337065ae2eb4aebcd25394790e512d796a6633d5a4c56df40a7481b3ac1294ced98451fb0c3fbbb7f378d40e6feefc104b8aa64ee1450653810d6a50b30cb22ed89b8aad7454beea1fe4ed5995bd315c0333407526840823537dda27ffd439a8decbf8c30e3d072876d68ec0e042f89cf5ce1c994c6ae223e5f8f7a85cc96a099d0cead3416726ea8a55b3626cb43acfd8e71ffa58cfbac9544aeda4d7b250b428ea97ed58d5578e38c2260a3c72cd98779e2afe3d4a35c136896068d5cdbfbc69a14fb11da7155bc58d0bc59fdb6e908f99222c51e8dba6763cb02f0a5293d05ba13a3937089ea47cfe45d601c496df77bd5d6f93dcc7c7a787a4a5bf731b76727a9da3361237f328b94d14d47f468ca25f7d9f86afde1a5204cf166e8a2c93e493303add80904a3ede3a2184ca108f6db1d6b55f3442504ff6862eb58ad3904874ea51d3edee4f7463e2483ace07a1a1f12d1deefb07894a012104adf92c50ba870e1c8cae511895d871c418f1d790629a9f37c3a48787b3a0dbc61178e8483976297509c279e9578be027393be05e3c7aa168a9be167a76ad61d825ba0975d2d61942cdd30f09aba74a0f83130ca4deff5baf6c03d6f4ca0e4aab4f2468772e1266fa1808f6fcac3333adfe809939adc0a419ec9ca0a32162889042f25f06f5e3b1617b566db4fb621e18681f5b2bbe99c5bed63e77da8f97c29564084cfc65fc9fd693f8ae04fe4e9adac371214c105ab9d78318f49927e935dc399528a143e52189d40b7dbf9fa6e83b5d1f119dc122137c54d22535779a91605f97074ec984da5fc993054acf9d0790808cf784c00a484c9f99253f7aa734fe002c90d09177c897c363b165d386bc97544e7e21ccbd5c684a50d7cebc399edcaa7b5a55dc22078252fce9cc97bef8f1aa75646b1527cec7d51bc31d5390250ef013dd401815d76021aecd43aef290e4411555b55960436f6ec9641860eb7aeb8d35da4a41fc6c1123edea5b3173f942d68cd5a9dc7f41b6833e97b067131c42a7a1bd5000517d1a0278bf6ba5af836204c00134179377384e43cef1fb8abbf06f6bb50cb19b0f59cfebcf794881ff592f58a1bceb4ff83be33373bb202f80b9a3809de6987df4b81a48edb77f2e3d53de1e06c41407b18453dbb837add424f309929da2890eeb43fd86578b5708101ca0152e6286794e4515d0488e449666cb0d51fc516aa81f12d1f8cfec0342187feb877b3fec80282013aeb76ca56c456301d400959fa3f481606b2a22124dd881d2643ff0b9da6ddb8e7b86e99835e1e7e680a4d04f1779cf42d89e1e28a1f3ff8a2e9400ce15d7d7d1401546585112c60f28adbfdf39790f0c0772f18ab8f96991abbd328807214e3083a75c45b2410d23b0231eca8effb4561bb6e658d6c87dbcebd70ce95f8a06c81dd1bd968c2406ad9803ef1df31688d29f71438911534744febf05c90d14454abc57ad78d542f7614eea74b0477b2f5772fef8efb3edc36aed6df85307668061a02e72e97fb7a30a918e41299918850990a030c88f29eb951e8c7c2c06559dd275246fcba3df0af203face9b6183d478da846d455d1903697893a97aa775497b479f36c244f6546104f84fa65ac278763285b4f9e7aee2fe7b095c47fad4771626aad76e77407a757dea140c0065a53793da26a41b147d7a5a0e4d288a701c559ad674bdaddd9b194f46e4bb60c3be77c703fa20c69ed398b8bc4cd1bf1eab8870e75fb2ab8f72d4cd69219f2974fd114740e19a561437cf79d14f7064828881d11bce10d79a9dd7ce8e19a3e95a1765347f82bcec355bbbd194e19b23f61166218eb05d97a1cf54bc670dafbfa0c72a7b9666468e86977fe647b67897bee0f92b0f4400f2ff1b134f6ed49b3988959981658ca8f7c7f69fa57a92a4632189a7390ce68474c7be64bd4b9336286134d824dde6db2fce3cfc92472450f768724afa3ca0a43a9a65440926a753a2e2e306a6345e9c72840110ac51b655209a3a13831dd597f5040c38999a3e4c13929a1d5f7b4f8d6af8a9f42ef1c1ec323d1c7a78a54c8924c63b5a6a266e3a11239c0a59c765b61741d653d844d36c98ef4ace7164e20854593d4a7207a7839ff466bd20d852e795095a2e4d5026abc0581869cb532ce795ded93b9fa93cbb28efb5047ded81e98dc56fbf86f62a734326f8b27c6d38886607f432423358623eb2dadbc41318eac1607237476bf5bd4b1f375a75c58bc331956a85afb45bb442ef9b7eacd91fd5793b624b06c9ea217b9c45da519d5d1b240f807d50293aebf4493d8f7ea4db6cc9539b24586de2e459ff1486b251369a8cb8aa850be11ec677771c7eeb9b04897e27a145bd11f2b32dd985b1518f0dc459bd0d1c095a5342707bc0d763db84ba2c7ac3c3a63fc193c0f3c02ce55874fa349e8f192ea79cec71e7df8a0befa0585df7e1fcbf7095da2dec46147f26716ab859c57c5d2040b97e5156479599bd9ea4405b9bae9fa00cf7945637d619786e6685c4f03cc3803ed460c56a01891740a844bf4c05677c16dc6b16d3dbc6d3d508aea3b91331638275405a6dea386bf815770303b70db13b0ad47747905c635a62c532df7f94c3ccb8e6e69c2b896c88e4e433825c32ac8b8d0737f9efcd61ccb826f9a5df73a709642ef92578f1c3a8faae4df8bff0239f7a5efbe8b1dfa6d6c897b9ba94d049dd7346702e4e7d98cc197eea670ed32786958451f270842cb0b8ba46562a4add2cdb008330d30bcefedc3d737f4cbab19d4a4bc7781da22f23ba8778931337052221c13400e35aefb8b014449e5a14dc91ab1c601dcbc944f741552d28e2aedbf020d183eca9fd6aa39afde7f39ac94506848814e626b942de6a6898a1df36f4ef41a4f2272ee3bf0c956f604ab98100082e71704efa41fc90c8e5cf43a6eb7c46e60f550e5db45e67ebb356838df2ccfefb5687ea891c3bae047aa1826e9bc60d36de9c8ac5b080c7eabb27d5e8fef3397e132df97961da73e1b46b7689bd31ffdf340458ad1645fd3917ea67937114000ee415ecbfe1870ef742289ac14f0bc7fcfc8ae78b18abbcf6d9f6c74c08db31b9ce5f678580942e7ddb7650110cbaae93b5b9d596c03a77d688c9396969dd9565a6cb761ff33a95857385c09b62ee632b2c384650d2f412ad4e9dfa7c872f258d39d4f4a2d0cf669b746cd47f5bf18e7308332f152958bf3525b533c0334dab92a4b87326c8e487da83b465dbf0b7709c3235c873236acda8633740dceaeb391bf7285205a9225b824f7a1cecad3437fdcbf3af229f3253546fc5ac5676bd8490a0a80235ea01a296fe0c641fd9168a65da8f1e867ebaa0636a58fc64517456e80b7f27d5f45183b907475145d53f3ca715baea5ff128241eba7a1b4d3df0a34149605b39a702800fd98b94e99e0062ffbc972ee6b466bf6b1baebab00b5097a0f75814a474781c2ba5a9ce2d595e51d4fb59f585e450c60d6df926862ff74a8f366c53bc9aa858efd309789a01342bfa4769160090c75b8add6973bb201f39053cc30d68cfedae3d27e94b1241bfe2feefc04de68082382bc71d778ca723c490abc00a096f8001b07a5ae60454fd3c0dd71d4dacf565f2035c739e9d84356a06f3e300375648ac13c5d93c1ab422f9e3be116f4d6000216e0869a08391560b17db4738bfe58914346e8937a679a6da0d05b62840c8236d729a9054ba7be4bbfb7c96488eb3af0ecea05763ce34078374a30df85ba7f389009c871a3d1be43301b08068ed653277e0138ee22d69abb67f0b64de30f6cb3e2dc95e88442940ea0a4e4d6c10600ca4a494e5d18ea4f371fd9fb1561d235ae2706e010e4a06437b4664388dfeda172edd9b33ce629043ba59ca643769bb4852f299d48d437fc5d6c41b3b91bfe647de90cbaaa571a204065e5f29468dc8ed86eaf9ea4936e8222190493d7f6c4195fa03121783e4e2d0eec14edb3966388c38f7f7cdfbe94c4da1db58b85a4f73efdec5864a02476a7f5a1a9b0ebc4b81193b34aca38afb7008cca324a6ad68f1800267b01bebb7e37d79a351e89a00e4cf4aeedd11f689b3d5ef67924aaadb9b7721a3feeb9875f246453c81795fc014a3be78e10ef1c068f0c6e76beffadcec31cfdfbe49bdb67e1d4348f8b9ace3df8289de22ab26ec2807e38677bd68cfe7c0433457b5e168ddfb26bd6359b4a5160bfdc1ff5608270a7bf52db4bbb85a84024a5662443ce09c3b4f14c602e8d529ceabe06f54a8dc425b7e53883758b55ea863286326301fdeb0fab0cac858e4c1223527efe3a571978b2a6e97ddecf36b846506b40bd56c2ecafc7fe0a5f58ac8dd545ce148e5797b0683d9ea5b7741cde7a67a5896d2bb09e1dfb64b65d98d4da8c4aad787302ad3391b2bc338bf9744b770bdc3d9759af41c3d41a88aabcbebe27f2ce1d3c149bc14021dfea9cb9aae3d91040aae7cde90a71d614ef7fb811587f90cf56be12b2d5c915705278b7f41a0d6a62be79e502332cc9fdf09e0e1c199635aeb29f88bdcb59edc59db1981b1683f5c36c7624cfe3fb589e939f23b5818ed10ba5f4772816beffdf2b27877836ff5b30ad0fe5fd868aa78f7123953d1a6c540f23193d44b2e6c90ea0145fdce2959fa1ed1381aaa6b074a3c8e4cd8684717582f847deaf115dac2d4159a864b9b933444082171dc8941c06a1f84d361ed59210394ed8ba9819d3d79b6596ac7d3be0e5d43990349b3462df4fd1018d3d0847fdda24cd3734d6cfd69f94a08faf350026428c433a1ffa5d5997f457144eac53c11e17bd0d67ef241d76c8bdd98d55e9130cf815be1085fb2509b6b417c96a355d6f46749c748fb96107b0f32a3c1147dce1b43d0f2dc91fc0775f7c5697931d2560ccd4059f65fbd0060e5e11b2ad43a4bac8264011c1ef47b149f3336ede7cca54094c3db5c6ac44068509083dc7224eee452be1d47d4d71a2ee92f422de75175bd2b593a634b79ddb09391b8b0119506ccf00e1755ccce612514bedc1d92f833b838b45e307511ffa71b3f111ae3e41dbcf73d2e7458a8434e5ffc6ced9f9081fb79b196330639adfb00cef829465bff34013e4022f77de93edafa50a1b38a4e219b650b4d17fcf2f34e9aec965923099ec308887e5b70478994c88ff27c168844647da8b4aadfed79d4cd0913a4473b99d58295a75bfe26097953312376cae3e4f2f107441bc6f40d6689d6a3f02883d5dfee3b09f799c8e1d9a9f132cfa26775b291df71a0b061bdbb7162617148be23ca8fc8e94d68b527e04bb05197bce11bcfb07eb1f070f454134c1af90692a79be8ceeb00a0df58fea6a6d9d33f99022ebe01b189c0aaf3e3434ea11d35537bdbf4b5d1df615d53d80359789b1ca0d103a0c89bee233bbfff3592de96c6a929e9beda7a98e72e4101153ffec78cb37d1c7632e8b159a0ebc6666f9e301d002f3e55a78375c89312b359558bf49dd21a83c9a66be5106f998af1a851b8ff8e96ed6e1c6e0027ff60a97a278f8b2aae9e469ebf4fb29d8c500d0cffc16fc55bc637c681f66ee07ebbbc2710f75ef1d88d87f29dd26fdfcddf7230197a05da0d264c09887c0cf0b53381d69474cc58e80ee031a1422d8043abe29c244bcd69aebd16dcb9fe14d7e1f4cd248eebc1fc2a20ef79ec8593bd08aae5f13b64f4b971a52913a437d67d7022439814a14edce97e97242a77bf4a04cd598b2d4d8840a59fce4b54a8f82a04fb015d661692c9fc8bdb768c736e43deb02394947d7ae1d67af5cbd6c044c042c47544a071158dd1fec096f469a0c91b1a779bbddab752b0af66a4800624c0d7e54674e3273c21ecb2edc84db6e366aff9245e5977e9bd29acddcdb69653f237d0194bf0a156fb8e950442ddaa762a3e3a549ca2f63a9eba4fbe46bac07476d1b17b968d1a5c953936ff2c5b489e48b0e0b5a2dafdb694ff6d158f4eb35d031963ed0bcb7d65fd19721b031173f43520d1e12a404d5e68aeafb0d0e4e8fb4b676a81d6ec15316979801221dee5ed0c2f6f727e23313549cce62d42579a9daab0cddd000000000000000000000000000000000000000000000000030f151a1f242a3303b43faf1f9fa9703ffe9548375e6e8d0be1ccabc61720a80a5602eb67d5aefc346e715413a6a6b2c123b71e8f1ba4d93fc51f915202a8965e9275f48b2d12f675e46757750f7f50779767fab0a59aee0f3c93f74d791f1b812e13fd7a7bf3f6d99a9f8c3697b9c0a838320166cde754494bbc2466e946f113ff993e27903523e34427d788b4a009c4425d35f9ee4a3bad14eea7bb69f36a5542af275cda390ae711d368b8ce697965d007056769d4c23391e9560d672e3f1a721a0209dc098d48b924306d7d20d947dc06565b21b39f26728e4456a9aca276c3f867815c4111c78d2fda78bb03ca82fcf5ca01348be8536bf6e234d2b8745ea980ae90b59adeb6cdc0be80337158de004e20af886d454bc837c75485444a60c082e114616fe30118070e92447ce75b0cc5dd1b7517d733eca431fd3445b960b736a8ae18cb026abd5dc085bd4be619e511c62ca1a3d5fdc0e12a28c62599e745337b409447a3d0f4631f9f5949437005f4a5f2cdc8f82327f398bbba14ba5d6292b3258e6d81d5fa50e2c85da0a431286f40c28541024081c9b1c6ec42cd0ba1a29f6ff33f66d6cbce6587dc2ef1a7309354fb488a25e922b0dd51e3ea62123ca9d1235379581d0ae509e132a29fd31ed83fcbb400dc6fc83135d2bc372285324eb6e513d3a42249ce2110cee32639f7ce4e7bb81961e4406cdc43e5518f87ce9dee4aa05833", + "tbs": "a96375736563746c73656c6162656c78184d4143554c412d50512d42494e44494e472d544c532d5631676e6f64655f69645820cf4966c74356a1435f52bbadda8e5eafee5d4d0300fe103a61e5f36ebbfa5971677369675f616c676f4d4c2d4453412d38372d505333383468686173685f616c67675348412d333834696e6f745f61667465721b000001a0acc226006a62696e64696e675f6964505e682139dd1576c3b791ad6323a701556a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f686173685830ba5d003ee50124a47aa0d55c045bb81cc4d8f48f8f2af53deb5f9814f14bd78ea652cab197fc2348547d4cde771bf49b" + }, + "tls_status": { + "signature": "99c4585cff2feec1330185b5322afaf3816b0c5d42cf18a736fc0fac9675f6f950e3c43d5b21ad589e4516812c62251abb75a4030b5d9558abf96abe22bd852841ca14b20d402d666ecd5ea4defb78292e53a2a421751f1bca5bbe8d4033b39d9acee21a66b02823ac6b09db373ab84fc2b7cf0551833bb185b0b937473e8783f099133acbf52566eda50b0ba6e86a5fe8aabcbfbb52847309a650579272c5cb4245f51d170182f5cf05988608b1e621a6f55687efc48863dcc2658947ec8d95939a3a8bef695e5847731dd0af240723f23939564f3b34bb59fece42efd88f603a1cb8e72cddcbcb6469557bd998b8cbc34fc40165c5e139d1b5a91fec6f1eee3183282628013438fd3cde4babe98c477ce969baee98cb45b8e890c0a871a5c89b1323ceae70446902889604c5517cac947e4e737aba6a8dbd912ff20856c6e05c55cbb9673ac50b30997a3cd9eb5d6b3e1569366d62ce01604b98c3bbb1776788f798a4a4168f789e9d209e1bc21a77c4000666f31ca998d637d474b9f4f91747def2a77fe846ce6ede8ad56969285617b37d9b5ef6ee29562154281eafdb5cba29bde4a4cc355f11a40a5d20938f5b6e8beb5c776002ff70f96d783d1dcc2318c9e9ab09a5b6cfabb1341b8de1e2078ef65d6740fddf50bcf7aa4d3f05a977efa07f01433acc4d86472acde79736cfc41080ed3145a69c6d6b0a33384a9fd8fa2cf531c672f5358d33bf48b26052b3d2f7974f3e05e842df434dbad15c76459f12f37e8a0bcf53c491d95491c910a37dbc7a4c8de8a365c6dcc4985f1dc0f22716ca7a4e6e466715bde03ea98da5222e96e5cd258df9c287c32e680e37423a985cde317fb49c056959675fb0f0072d5cb12b1a9aed9eec5bfeda6536dde2dd6f12edbeab7c1f3b9df7aba6071c81c4d2ff791dedeefc2d00efa93f341365972fda3703b0f22b4baf3a4f5ee7c5a768efe3f4324b23c54b38c4da144f298f767ba09fb9d0cea33b65d4ee41f0cec3b4788f3c7d5415853d1b6783ec1b31c5f89f55a121d539e30383e26bb22fd39104ff2b2415085d7ee74cb878e4a42d8137b71d69a4419061605064466738d2eea4b99afd9cca89afd98a94d71a0607ab687c804e76b4744274143514891c19a74c4ad6cfca9329f12f35e0be72eb00a6751e21f65d4b89d677fe742a63a11630edad4be6969bb53ac26c99c1c66ca5006a30e007b92244d5e95a0f304c3af3b5a65b2ad383bdd2a4189a1f02684d7f2eb0eb23b98d21cf5e0f475abadb749a87935bfc5da917befc86ba8a1c15fff9e1174e6ef0fb6efdc7cb11fadbdbed5e9d63fc928cddd1f79a060d16e3c106b09ca17a193ef369598b7f731198287b78a3cd0eb91e8a1170b38a50d500befbe3a0b05095fce6f5507b4df29c1697419fe9ae7b093a9bf02d5b09ab6bdaab009ed94bda67e1d9a83e2472abecb32fdc0094cea0d89d453a7b7bcb895a66b5eb089c029237fbaf52b2d0fa1f7c29cb70961aa53339482e215caa71d9aa6bd83123880e8618b1a2f1db5d36abdb1283e023eee639860eb069eb8e374f887272abf6e278f922c452901ab67c998e048b828910aeb6dcbaeb187e216dcf1626c65dc61e229cf512a8bc688512c2badac8d1ec8d6285b7279c0f053556a96883a4656ad20dcff8b6ca7f233dff183e026e1d4837fc2ea3a1710e514ad3fa8856d4f51500f5f5bdac424bdfe4376df3c899fde1a50600f68ca3c03eaeea4db3f06833b20fc4924e1e07987ffd818c078cef2bdcb3ce6daf8c8a7ec8c90f8c7af22f91c6cab1b3ba05e97f301f73355b5d80dab6429100972f798b65b218c7b39101c946d89f41a374e4e558da1786346eead3cf740b5f3fa78bde48e376d83c90592408fccd3fdf7ef94f368b4723558d8cd60fb2267a0b6398098de680da4afe555691bbf986f5317c7d702a24730c53e360184945fbeacb6670f6020872a9f0870d609e4c229918f22818fdd340a70cb733ec4a42616cfba5cc8b68073c867b494b47b83a426edc143fea0fdd4a0d62c10f6e73d25a39f35b4e66a2369bb613393d583645532925092456fc117606ece0aa5d15378edaf033e47d2d8b3ca933fa4bb86f5c03c13b6cdec7f953422893f204fad87d6794eec8abae7984c0ffa455798e9bdcdb3f09c0b1ce81bf8790e28008cae51229818f4820f3e0ff7c1c0467ec0d32c684e88a4a2e62cfbe0eabf963c28f55435198aa896cc461a7ecc894c2495db76864045a4fe0c29ff1a76d9fd5d95b3be74456a44087dbafcafa08c171ccebe4058ebac38b57ff3f6cb7e704f29743b0d2aac3df05ad50fe0e07428e58179c8b7724c26a293f1524f5ac1bb0b4b471ab9179140ae4067a658e300497bab43d251d30ae9ef12e7721f503b33650bf69d698c419ac95c54281a797f54517065010edd4c42a3f02027c0a6b55ad8bb8ea53a01704aed3a4ec56645629c11c74405c71ba880f5850603e33f20ea68f115410c5a5512fee97e40ffa77fc6af46e37371906bdb078fff321e2d9d22d5f0ac06c4afb994e94f22dba37a3aad3b62bbe182baf4b4b4a770b216d79ddd7fe4acf91f8d979ecb02fb65e80c6d046119d28169820f704e8fc71170ddca1e614db0fb59533964461121626eb812ccbbd11f2679aaed86520eca3c004d290ef9d57731484f311f1a126b92b2aca4a913335fad66c4dea6508f4f704052a9aa840b6fae21fead75dfb5d6b661f434d87aaa58577a6a62da56d9b4c4a4c1c7f6ab7abd08256ba00f2a2237d5034d4b8fcf5c9fd27eb669a5dd9343b27302d4bee463b659d2dc7807fb934f57611bef4c42970742f295ddb45f70120d0c4f11d4b44b1ff8f4e2d49b809608589ffe3802656dc1fed32caae6172be7a189106f118b83e16b3decc89fd4e24d6dcc260def33002c6dfc81be834a0a50dd5a11442434d008403a0fa45739563f44ce70d38d8ee3a5a95f315e6a979bf9200b5cd33408c34f65b7f33359c1cda1b2ce4bfad4442397f87d7f28ef22b59eb59ea180c4175aad4488032c3afd0ca657099e3451a4655481f601ad1cb6f57a3566a4a962a2ed0472fd46089ebc1d8faacfe494dd997a2de9cbc0754ec4f6965e255724a139958020b712ef8f42f4141e3315d83b26f6a3d4510f5f93e1f436e738af42bf53d4e33907834b4856935f2cbcd34db13bc927e10ddcce46edea77ceee50649d6ee537f55f854774c37cd67d39e8a2859d91818c16c942ae2c14f39a997b859f1fff68f5185e72e1dc3b5425a5c722b6e5cf752be01d5637b7804fb65e4dd5669fc525c31c69bdd8fd90dbd2a49c8404fbfb6dd8cf2d2abdf43a189d0d12a5e4fe5eea0c4f2be30148410ffc08c717bd82e0eba01fdcece089075fc1d7372f6d0fc7973465165b89c6378517f83dd07c76221e5c2fc4b2d4b7b05905d9c7be22e8fede24c1597fc67dd87f06d11e6e7a16475ffceb88e8ccef362a7f1be8611dcccf2264a82be0d9daba46cbf8f79cae0632db54c1538530fa9c9145728048d002f1f3b1a168c2f3a08bbb4a68601969ad5c91b1055ee737ad35312d7c57dbda388c208ffca6c987c660f5fabd907c515760c92d8bd696d5c8c4ceb22c5560a5d710b34d990b1608924e673ce4db702dd7e2acda622b9a6d19c437a9a366267e1a52b4c2683950381eb1358e2ed04c02592faa0e3a6ad59d98db6d8d199eaa6f3b0c01d533b4c55ed4ef59ca5631dc24a73a1357dda461814a39187ab3fba9cad6b94a0ae168cc5600b03f331398b6b6629e80a1e41a18427c43f64390431e5709b166d961143901a247f7f9aff4c60aecc8458106694fb3723d68241215e9ce681ed57a074157d790e2e013585ec926992843ed1ef1766b0266f5745d21b82b7457e93bd734c6f0ba465dbc830b45874cf2fd036014aac28aca70621217fa97f9a4fc06cde56becf090f7093cd6f2a07f40d9990d153ed150889fcf98799d524f833c32045ce6aa70c92fa15989bd74ee67d6a85fac55ad8466fb1f57b218698fda55146eb46bbc7cc5099099f14a0dd15e1172a799c88afa6b5d3be427df8d6662967cfcbb0725e170a0b6d28496a42470d1158bf3dc7d5006e166937e376729ee0686a0961bd18009f0834d26b2ef2688aaa0426eab447fdbd8804fe27d48a3dcc94fad17a2f5233b6f49af1a22852467e0c7ecdae8a089e84f20a359d8d4a6e6c3c8c9801ee646f9f92658e5232d88524a139eb2c28bf41e3893db52c74634913fc24bf5e47f2f6b785cd83c82f09318cd15c0cc1c78087485df0984c3599425e6f35be097e80ee288c722778ed418adb267accd864525cf1eae539634eb7b0bd8f83223ff9323ecda0e6c7558bfb88ea596e512d282af8fbc691f9504a999b614f15b13436d87ad95a6b3d6748ecb33ad548011ed7e60ffae2524727b0a1ad0b453525b103ff4d90a268150e69ff9067cea32c4f6ebf926582c89ac489303d87ffd39386905dedf544006eba9761c66b08c1641005ca11d2be54c3b308e8690792c0a6e34c3aed5da45af20e44fb9abe06d679ee5c2d9057327692fb2cfcd2b5be75335d1b04011169e5f5dcdfde4001f5b11d786745e0e9c5d36dec708cdb4680e810aba3c421ebc2f4d703a234f19b3e18846e833be7b01be5d5dfd91ba95bd272c4cf95ad1430258dcf795529a2ec03cdc5084137ad614c15a6e092202f83fb9767400c533fe38c008546c82297d25090d18d55987c8b669de947434ed309e69ef8e1c440197f39b58136626e93456b722ba2999d343adffa3d1435ad1b585022d85d6b81a9d4cd50f18338473a50b52cc2aa06a168409319e5697e2fa2815983e2f21d0b99a1259e624f09e4533993b3b63c17178df152f53146c4c9c92cc0a8d415f7694648f3247aa01bfd23b633a6632de9de1e6d90e6d704930906fa3fd49f6030a7311666ddbfcf774a7c1eae217a419a994ffe1f5ac0c0dd412e2b92c7bcc38ffefbc7d281e84c90042c49d8e924d78c2770c84ffb3a97b895a815955679ddd9b6c970cf2957d1a57574db8dd2fd5480211cd5b0cd9ea8264062bc1f71cee08d2113bdf723dc2ac0febb2c4051ef6bb9ac67ffb1baee1d25ffde8cd9926206881f03d0bb121664f888bb1828726ff19ad2daa95a290952c309e565ece3045652ad98192ab2bf379df17c7f0bfcf692b4f31a4456201da370f413776f0332b53649c1b3972b843ff68934a785a8419a43d8dab2059eda5224f3d7a4e580599cb648bf8a3287ae4171be19ada1851a2daf1764a3fdf6381b4cdf22fffa9fb3a7bb59c7092790ffc5f8aa6a48c0620d6c768108b25b2a967e90d9d2a938017936074eb32ccab4e8629009e74f880cddfebf3852b75460ee9bef7c641923e4ab4daa3f62288bbd8cc0f2014e0a9f301abd309ff4b2b9055157227ced167317d12ec2c2c980c1b62e55441b6c5dcb2945f4e5965fd596ec4702964559c8d15631de8574f3e7496065287007ddac7a967ee15f627988b3de89265e67bd9f63eb58f40ee8ea02b85e57d22db477bdbfa3040533ca4cf9d400bdb9bca73d2b8d39a695640dd44f55cc3c0172dfced55879b71e0e13d07df32a74290af33d19185cc50b5befbf1df3b3599defad7e839c933a63451f46ece5a7e1316e5b8915e00212d6e4458779c88e4f4201975ca6d184082661d53d325ee5f31bbbba0a4422ba7df67a16dc2ee5df540844ee80667b6bcab99c1c4080a29ed58ee560e35a2a05611ad4f0f58f377a744d2a6e1ea87c915ad60c65847e9795b237e87b8180afa3af7a60e7b8ad53ba2f7cffe8d891a86178c8ba1b82b299978a9b316cf6431ac4b4961968a1f5a95f8edd6d7de61053012124262fba1eade6bc0399dacca52eb4a35da06a612078027d995c7800e9ec319031e19e45580b22ed908cf612cffe77c9db6240b6b77e7aa9dd33d3c8ebc9e7240b94b4355e158db765aa2c793c9c2102656856c3ca9443356f328c5cbd3e2bc941af31c5e577ef5dccd39ba03fa991ce428eb1173cf869fecd6dbe1db8e80600852e6ff613d607a7712fb27cade11d99c745618e1c0125c28fa680d8e3611a57ec6b4de1927d8143e007a0b415c52eb9a6f23daacf246ce7ee36b0b98a9525ff2bb4ace95e679f65e1ee7f2d090d4566ad3504e89dc41d4dcccbbad0deea3c6263082f1bc48285680dcb9f248397de724ab7fbbcd0efe353ab39d4349fd2586ae566f9034ad1f98e2478c644edac4ace54585f2df0bebbccf123f30553b1db64d0649f57181982c2ce7e85739c1c596292697b6212345fed7bb56a7ca72033e59da4626ee595a05972bb56dad75d642a4d339f8e121394400c99b6a4bde34eeff2d1d1730fc04c25fbec13750ffe6e42f03aec2e358028bca58d29a8ded3bb39364c4f7face50a323a4579ea063f828996ac30445d667f8de5ea737cb1d1d7ef3a5ca5df08172d3091b3bb000923508a9aaecadfe500000000000000000000000000000000000000000000060c121a20242b3577b6bd2e58ed4d1012190de2d8fa383916da52eefa203a3d97477c80779c2c776e5a8d1ecf9da5b37c258ad32f81537fe810c923f7857d1aabe3e7ffbf70b2452b8f50feee0c04a413acaaa6958d61717496a50513f09798b969b2bc456f5d9c4feaf4729dd5a8010f399d0c6586878199781386fd3a7c8e6e585a2343dbba2be4626c0ca5cfdf71aa6e9738730dc21f4281b71c00e9ffd1a044f0322afdb0d4e078e2538aedf8e3995edd03626b59a10f6825090742e42db86943b3fb9e6e18f9ec75af29d03ec8502e50548ca9f54bed7d12d631a1405cc9d9b41067be5f865ca21c7bc1272e1c8c68fcfcb2929fed2552c13d00ccb419f876f7b97e7191b65a08764fedb3fbb1390e7b6773bef62140fbc5b08cdaddc86fb26643f4b9b6ccdba3caaba54d6578bd65340a395d8b9632d8653296de49122224de68649c5ac5efc33cb3c25aa9add9ba268bdbd3a5ab7b1254d057926c70edb0981e4543283259e0b9d483dfa4f0a4f2d302aa97daad74d58b7e4e9bfcddd9204e0a9097b8c4d6bc9822155e0efb252acfa88787c7b09eb341cc579ffd6f1b4014076d1e9da9ebf7d36a2fb11dcc1cd8b23ff67b735b2355aec2689b31d9921b3236be7b9ad202c0578f7e211cbe315f5f101ff42e360cea141564a7e09d0e7535e66b7b99fe25b3abb9f9f974fc6ea2c93099b657137ed65f8b410933f5fe9e790d6d693105", + "tbs": "a6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f69645820cf4966c74356a1435f52bbadda8e5eafee5d4d0300fe103a61e5f36ebbfa5971677369675f616c676f4d4c2d4453412d38372d5053333834696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f686173685830ce0ff6e30724b72fbe651b5a5cff6e24e2170874d87536cfe4336c45becb5cb0011c76e1d0a1801f4b313e1cf55ab6ae" + } + } + ], + "generator": "macula_key_bindings and macula_node_keys at macula v12.1.0, OTP 28" +} + diff --git a/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/m.bin b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/m.bin new file mode 100644 index 0000000..8fe2a4b --- /dev/null +++ b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/m.bin @@ -0,0 +1 @@ +The quick brown fox jumps over the lazy dog. \ No newline at end of file diff --git a/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/otp_message.bin b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/otp_message.bin new file mode 100644 index 0000000..1bda0b7 --- /dev/null +++ b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/otp_message.bin @@ -0,0 +1 @@ +macula-go interop: a composite macula signed \ No newline at end of file diff --git a/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/otp_pk.bin b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/otp_pk.bin new file mode 100644 index 0000000000000000000000000000000000000000..3de31a0bfa9625a29596600ac49ed44975cfac73 GIT binary patch literal 3118 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 B{1*TK literal 0 HcmV?d00001 diff --git a/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/pk.bin b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/pk.bin new file mode 100644 index 0000000000000000000000000000000000000000..707e0cfdd2997bce76a28167209f6c3aaff8125e GIT binary patch literal 3118 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 I0s{d60p|A()c^nh literal 0 HcmV?d00001 diff --git a/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/s.bin b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/s.bin new file mode 100644 index 0000000000000000000000000000000000000000..6a70f32766022d44d41fa555b50bd4316e3c8431 GIT binary patch literal 5139 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 AzW@LL literal 0 HcmV?d00001 diff --git a/tests/vectors/manifest/erlang_manifests.json b/tests/vectors/manifest/erlang_manifests.json new file mode 100644 index 0000000..441cf04 --- /dev/null +++ b/tests/vectors/manifest/erlang_manifests.json @@ -0,0 +1 @@ +{"manifests":[{"name":"626c6f62","size":600000,"chunk_size":262144,"mcid_hex":"02566b1345e2a067b6d5218a4793d9cf2e9cbb94553d70350fa861fd7f94e9b1416c492863a2ff47c0e5b76c9d3da83bac8b","root_hex":"b7dc2ec499dba61778a5fa26af1623fb42851b880cf469763fb0b8d6bc32e6e647471401451e463493b4d94476e59ae0","chunk_hashes":["26e83f50efabe15008b4baff5e4ed620b4d5b02d5c27f5e3d72d7997c4311008a43a8b4032d12524cff9c4925536c481","17b75cfa9b9f05a7215030516352e1b27c7583341610bb7864b8af22884233273238d04548310ca2471cb0ccc330621c","4b984f8d108179a12803949eec03f8ff9cbefcde3728798f9114261c002d83c3448cd6a62c485517ff44aa0047b71e5e"],"chunk_mcids":["025526e83f50efabe15008b4baff5e4ed620b4d5b02d5c27f5e3d72d7997c4311008a43a8b4032d12524cff9c4925536c481","025517b75cfa9b9f05a7215030516352e1b27c7583341610bb7864b8af22884233273238d04548310ca2471cb0ccc330621c","02554b984f8d108179a12803949eec03f8ff9cbefcde3728798f9114261c002d83c3448cd6a62c485517ff44aa0047b71e5e"],"wire_hex":"aa646d636964583202566b1345e2a067b6d5218a4793d9cf2e9cbb94553d70350fa861fd7f94e9b1416c492863a2ff47c0e5b76c9d3da83bac8b646e616d6544626c6f626473697a651a000927c0666368756e6b7383a46468617368583026e83f50efabe15008b4baff5e4ed620b4d5b02d5c27f5e3d72d7997c4311008a43a8b4032d12524cff9c4925536c4816473697a651a0004000065696e64657800666f666673657400a46468617368583017b75cfa9b9f05a7215030516352e1b27c7583341610bb7864b8af22884233273238d04548310ca2471cb0ccc330621c6473697a651a0004000065696e64657801666f66667365741a00040000a4646861736858304b984f8d108179a12803949eec03f8ff9cbefcde3728798f9114261c002d83c3448cd6a62c485517ff44aa0047b71e5e6473697a651a000127c065696e64657802666f66667365741a0008000067637265617465641a6aa1f9406776657273696f6e0169726f6f745f686173685830b7dc2ec499dba61778a5fa26af1623fb42851b880cf469763fb0b8d6bc32e6e647471401451e463493b4d94476e59ae06a6368756e6b5f73697a651a000400006b6368756e6b5f636f756e74036e686173685f616c676f726974686d66736861333834"},{"name":"6f6464","size":2500,"chunk_size":1000,"mcid_hex":"02563f3bd338869de9e519e7d4c1cc48dad8f847bd7752e1fa76be1a53c2af913a1d473ef41486789ebfa63f58ad62360493","root_hex":"b4d1f7b0bac8b14f7cdb6526ff7ecb3e61ce279968281a0995ffad3c0661db66a18ba0e1439d2005c665a677bc53fb9c","chunk_hashes":["7a2f8c7f12344964a13cb9260492b845e56615d6152b9eb9e54b580fc88405e64f31813bfda10de2a642fdf1676c61b4","e0c4b84a0cdcbccd467f329246cfe85aa82fdcb9ac57dd1b09f072eafcafebf3bf9aa82fb74fb8283b1cc99e28579653","547bdbba993465a1323175f8bdc0192c8961344bc2a0249f62c59900d7f9a336fbffcd1489fdbb268656af4fa4f96565"],"chunk_mcids":["02557a2f8c7f12344964a13cb9260492b845e56615d6152b9eb9e54b580fc88405e64f31813bfda10de2a642fdf1676c61b4","0255e0c4b84a0cdcbccd467f329246cfe85aa82fdcb9ac57dd1b09f072eafcafebf3bf9aa82fb74fb8283b1cc99e28579653","0255547bdbba993465a1323175f8bdc0192c8961344bc2a0249f62c59900d7f9a336fbffcd1489fdbb268656af4fa4f96565"],"wire_hex":"aa646d636964583202563f3bd338869de9e519e7d4c1cc48dad8f847bd7752e1fa76be1a53c2af913a1d473ef41486789ebfa63f58ad62360493646e616d65436f64646473697a651909c4666368756e6b7383a4646861736858307a2f8c7f12344964a13cb9260492b845e56615d6152b9eb9e54b580fc88405e64f31813bfda10de2a642fdf1676c61b46473697a651903e865696e64657800666f666673657400a464686173685830e0c4b84a0cdcbccd467f329246cfe85aa82fdcb9ac57dd1b09f072eafcafebf3bf9aa82fb74fb8283b1cc99e285796536473697a651903e865696e64657801666f66667365741903e8a464686173685830547bdbba993465a1323175f8bdc0192c8961344bc2a0249f62c59900d7f9a336fbffcd1489fdbb268656af4fa4f965656473697a651901f465696e64657802666f66667365741907d067637265617465641a6aa1f9406776657273696f6e0169726f6f745f686173685830b4d1f7b0bac8b14f7cdb6526ff7ecb3e61ce279968281a0995ffad3c0661db66a18ba0e1439d2005c665a677bc53fb9c6a6368756e6b5f73697a651903e86b6368756e6b5f636f756e74036e686173685f616c676f726974686d66736861333834"},{"name":"6578616374","size":3000,"chunk_size":1000,"mcid_hex":"0256cbd544d59b1dcc7a9c532f0de920e89df1d7ccaf5e6b12e563b6b3a3634d2b35da4593bde2bab641f10196992dbb192a","root_hex":"b0a1814a5782b3de118e105ad81fcbb53ca00aaf346eb6fa83eb4d2f9068aa5188b3b8c55176db06c40b0090d5e38250","chunk_hashes":["7a2f8c7f12344964a13cb9260492b845e56615d6152b9eb9e54b580fc88405e64f31813bfda10de2a642fdf1676c61b4","e0c4b84a0cdcbccd467f329246cfe85aa82fdcb9ac57dd1b09f072eafcafebf3bf9aa82fb74fb8283b1cc99e28579653","e3557155a498f2ffa97bb796637a5ad696f757b5d6e28b2bf9fcba1801d47ad50f19877da8dbd873a5685e6a98c4c2cf"],"chunk_mcids":["02557a2f8c7f12344964a13cb9260492b845e56615d6152b9eb9e54b580fc88405e64f31813bfda10de2a642fdf1676c61b4","0255e0c4b84a0cdcbccd467f329246cfe85aa82fdcb9ac57dd1b09f072eafcafebf3bf9aa82fb74fb8283b1cc99e28579653","0255e3557155a498f2ffa97bb796637a5ad696f757b5d6e28b2bf9fcba1801d47ad50f19877da8dbd873a5685e6a98c4c2cf"],"wire_hex":"aa646d63696458320256cbd544d59b1dcc7a9c532f0de920e89df1d7ccaf5e6b12e563b6b3a3634d2b35da4593bde2bab641f10196992dbb192a646e616d654565786163746473697a65190bb8666368756e6b7383a4646861736858307a2f8c7f12344964a13cb9260492b845e56615d6152b9eb9e54b580fc88405e64f31813bfda10de2a642fdf1676c61b46473697a651903e865696e64657800666f666673657400a464686173685830e0c4b84a0cdcbccd467f329246cfe85aa82fdcb9ac57dd1b09f072eafcafebf3bf9aa82fb74fb8283b1cc99e285796536473697a651903e865696e64657801666f66667365741903e8a464686173685830e3557155a498f2ffa97bb796637a5ad696f757b5d6e28b2bf9fcba1801d47ad50f19877da8dbd873a5685e6a98c4c2cf6473697a651903e865696e64657802666f66667365741907d067637265617465641a6aa1f9406776657273696f6e0169726f6f745f686173685830b0a1814a5782b3de118e105ad81fcbb53ca00aaf346eb6fa83eb4d2f9068aa5188b3b8c55176db06c40b0090d5e382506a6368756e6b5f73697a651903e86b6368756e6b5f636f756e74036e686173685f616c676f726974686d66736861333834"},{"name":"656d707479","size":0,"chunk_size":262144,"mcid_hex":"0256e6cd514fb23ed0ab26d9b6a105b7313426ab5361d9dfe1368f799061176280e6e8b07a7ce67f07cfadc920d5ea5b79be","root_hex":"38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b","chunk_hashes":[],"chunk_mcids":[],"wire_hex":"aa646d63696458320256e6cd514fb23ed0ab26d9b6a105b7313426ab5361d9dfe1368f799061176280e6e8b07a7ce67f07cfadc920d5ea5b79be646e616d6545656d7074796473697a6500666368756e6b738067637265617465641a6aa1f9406776657273696f6e0169726f6f745f68617368583038b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b6a6368756e6b5f73697a651a000400006b6368756e6b5f636f756e74006e686173685f616c676f726974686d66736861333834"},{"name":"6e61c3af7665","size":10,"chunk_size":4,"mcid_hex":"02569ce99a71f6dcd33451fbd63483ced251b718f85b1edc3763b46e4e1e4ceef76de78934a1030b23d2ad3e81659c85c147","root_hex":"7af7433cd31d22d48ce0e02d79ab4c19958bfb0a81febbdb414f5b124a9076ea91de524d89e152683ba32e3b74cd690a","chunk_hashes":["80ae432e757826025095ca1fa4f89c06c8ba6754b1d883a8e31a1e65fcfb820bd74acfaca3d939a574ea408a74162d1d","0802c995711c267bf6895728f82a2371655912797ec4a942eba05d9e46a0abd8d2ad88c61e0971a13c44afcf4de7f279","f06846d5d0435d8b39fb6ea032011743359d84a9efafdb30edec842d9a78a87bdc299679d1f44e124217b50a02902edf"],"chunk_mcids":["025580ae432e757826025095ca1fa4f89c06c8ba6754b1d883a8e31a1e65fcfb820bd74acfaca3d939a574ea408a74162d1d","02550802c995711c267bf6895728f82a2371655912797ec4a942eba05d9e46a0abd8d2ad88c61e0971a13c44afcf4de7f279","0255f06846d5d0435d8b39fb6ea032011743359d84a9efafdb30edec842d9a78a87bdc299679d1f44e124217b50a02902edf"],"wire_hex":"aa646d636964583202569ce99a71f6dcd33451fbd63483ced251b718f85b1edc3763b46e4e1e4ceef76de78934a1030b23d2ad3e81659c85c147646e616d65466e61c3af76656473697a650a666368756e6b7383a46468617368583080ae432e757826025095ca1fa4f89c06c8ba6754b1d883a8e31a1e65fcfb820bd74acfaca3d939a574ea408a74162d1d6473697a650465696e64657800666f666673657400a4646861736858300802c995711c267bf6895728f82a2371655912797ec4a942eba05d9e46a0abd8d2ad88c61e0971a13c44afcf4de7f2796473697a650465696e64657801666f666673657404a464686173685830f06846d5d0435d8b39fb6ea032011743359d84a9efafdb30edec842d9a78a87bdc299679d1f44e124217b50a02902edf6473697a650265696e64657802666f66667365740867637265617465641a6aa1f9406776657273696f6e0169726f6f745f6861736858307af7433cd31d22d48ce0e02d79ab4c19958bfb0a81febbdb414f5b124a9076ea91de524d89e152683ba32e3b74cd690a6a6368756e6b5f73697a65046b6368756e6b5f636f756e74036e686173685f616c676f726974686d66736861333834"}],"block":{"data_hex":"68656c6c6f2c206d6163756c61","mcid_hex":"0255c3abec4b457ff517e8bcb526365c9903372a3e007928e2549ece50386a2ea867988868a3afaa65134f51ec51097aeb0c"}} diff --git a/tests/vectors/record/own_namespace/README.md b/tests/vectors/record/own_namespace/README.md new file mode 100644 index 0000000..09b23d0 --- /dev/null +++ b/tests/vectors/record/own_namespace/README.md @@ -0,0 +1,31 @@ +# Own-namespace fixtures, shared by the SDKs + +Signed procedure advertisements in a node's own namespace, `~/` (D25 item 6, revised +2026-09-24), and the verdicts every SDK must reach on them. A procedure in a node's own namespace carries no +authorization. It is accepted only when the 64 lowercase hex characters after `~` are the advertisement's +`advertiser_node`, which verifying the record binds to its signer. + +- `pq_pure/` and `pq_hybrid/`: one advertisement per case, the record's wire form, signed by a throwaway + identity of that profile. +- `verdicts.json`: for each file, its profile, its procedure, the time to evaluate it at (`now_ms`, since + advertisements expire), and the two expected verdicts: + - `own_namespace`: `macula_record:own_namespace/1`, the rule the station's two admissions also apply; + - `verify_authorization`: `macula_record:verify_authorization/3` with no realm key, what a caller decides. + A verdict is `ok` or the refusal's name. + +| Case | own_namespace | verify_authorization | +|---|---|---| +| `own_ok` | ok | ok | +| `own_other_node`: another node's hex | not_own_namespace | not_own_namespace | +| `own_with_authorization`: an authorization attached | authorization_not_allowed | authorization_not_allowed | +| `own_uppercase_hex` | malformed | malformed | +| `own_short_hex`: 62 characters | malformed | malformed | +| `org_without_chain`: `acme/ring`, no chain | not_own_namespace | no_authorization | +| `own_hex_without_a_name`: `~`, no `/` | not_own_namespace | ok | + +The last row is the rule for a name without `/` (no namespace at all, so no authorization is asked of it), which +D25 refuses at advertise time; it is here so no SDK reads `~` alone as an own namespace. + +`test/macula_own_namespace_fixtures_tests.erl` holds macula to every verdict; macula-go runs the same files. +Regenerate with `scripts/generate-own-namespace-fixtures.sh` after `rebar3 compile`: new identities and new +signatures, the same cases and verdicts. diff --git a/tests/vectors/record/own_namespace/pq_hybrid/org_without_chain.bin b/tests/vectors/record/own_namespace/pq_hybrid/org_without_chain.bin new file mode 100644 index 0000000000000000000000000000000000000000..40aec50095016894aded8ce6cd4dfc8b3ef998bd GIT binary patch literal 8549 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 literal 0 HcmV?d00001 diff --git a/tests/vectors/record/own_namespace/pq_hybrid/own_hex_without_a_name.bin b/tests/vectors/record/own_namespace/pq_hybrid/own_hex_without_a_name.bin new file mode 100644 index 0000000000000000000000000000000000000000..dfdfb402eb3518f4975c153c969982837a86c0e2 GIT binary patch literal 8606 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 k3KzpM(%ABtO2yZ`_I literal 0 HcmV?d00001 diff --git a/tests/vectors/record/own_namespace/pq_hybrid/own_ok.bin b/tests/vectors/record/own_namespace/pq_hybrid/own_ok.bin new file mode 100644 index 0000000000000000000000000000000000000000..056d199e44d9857c246bc539379f9c7ea3076dc0 GIT binary patch literal 8611 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 literal 0 HcmV?d00001 diff --git a/tests/vectors/record/own_namespace/pq_hybrid/own_short_hex.bin b/tests/vectors/record/own_namespace/pq_hybrid/own_short_hex.bin new file mode 100644 index 0000000000000000000000000000000000000000..5a9c815fbf37170f95741c314fb5d65d2df0fc3e GIT binary patch literal 8609 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 literal 0 HcmV?d00001 diff --git a/tests/vectors/record/own_namespace/pq_hybrid/own_uppercase_hex.bin b/tests/vectors/record/own_namespace/pq_hybrid/own_uppercase_hex.bin new file mode 100644 index 0000000000000000000000000000000000000000..11928425c16158b48c835f7b4e39f841d7e4e4c5 GIT binary patch literal 8611 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 F`ah@6b@~7R literal 0 HcmV?d00001 diff --git a/tests/vectors/record/own_namespace/pq_hybrid/own_with_authorization.bin b/tests/vectors/record/own_namespace/pq_hybrid/own_with_authorization.bin new file mode 100644 index 0000000000000000000000000000000000000000..fa06ad903f86c308b27619c0e9def90e0dab5212 GIT binary patch literal 8665 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ξACcalci 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 literal 0 HcmV?d00001 diff --git a/tests/vectors/record/own_namespace/pq_pure/own_hex_without_a_name.bin b/tests/vectors/record/own_namespace/pq_pure/own_hex_without_a_name.bin new file mode 100644 index 0000000000000000000000000000000000000000..95a4071cc96134ac872978e52eb92012237ca7f3 GIT binary patch literal 7562 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 literal 0 HcmV?d00001 diff --git a/tests/vectors/record/own_namespace/pq_pure/own_ok.bin b/tests/vectors/record/own_namespace/pq_pure/own_ok.bin new file mode 100644 index 0000000000000000000000000000000000000000..c9a548e8b2eee5d72c72078eeee51a8cfb6bb250 GIT binary patch literal 7567 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 IoPuKi2b0Qi7XSbN literal 0 HcmV?d00001 diff --git a/tests/vectors/record/own_namespace/pq_pure/own_other_node.bin b/tests/vectors/record/own_namespace/pq_pure/own_other_node.bin new file mode 100644 index 0000000000000000000000000000000000000000..7a9424630358a823ff7dcaeb0eb6d6f70f31bf85 GIT binary patch literal 7567 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 literal 0 HcmV?d00001 diff --git a/tests/vectors/record/own_namespace/pq_pure/own_short_hex.bin b/tests/vectors/record/own_namespace/pq_pure/own_short_hex.bin new file mode 100644 index 0000000000000000000000000000000000000000..c71d08a14c5317ce0fabc06454fa0bd667b080ac GIT binary patch literal 7565 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 II0VK150KydfqZoz`f;_gm>;1XPdOaIfJ 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 AB>(^b literal 0 HcmV?d00001 diff --git a/tests/vectors/record/own_namespace/verdicts.json b/tests/vectors/record/own_namespace/verdicts.json new file mode 100644 index 0000000..87fc021 --- /dev/null +++ b/tests/vectors/record/own_namespace/verdicts.json @@ -0,0 +1,115 @@ +[ + { + "file": "pq_pure/own_ok.bin", + "now_ms": 1790363855305, + "own_namespace": "ok", + "procedure": "~07a00cda919d23fd9cbf8b4b39eed85fcbd72e3922fb2eadc34eab99de71338c/ring", + "profile": "pq_pure", + "verify_authorization": "ok" + }, + { + "file": "pq_pure/own_other_node.bin", + "now_ms": 1790363855308, + "own_namespace": "not_own_namespace", + "procedure": "~0000000000000000000000000000000000000000000000000000000000000001/ring", + "profile": "pq_pure", + "verify_authorization": "not_own_namespace" + }, + { + "file": "pq_pure/own_with_authorization.bin", + "now_ms": 1790363855310, + "own_namespace": "authorization_not_allowed", + "procedure": "~07a00cda919d23fd9cbf8b4b39eed85fcbd72e3922fb2eadc34eab99de71338c/ring", + "profile": "pq_pure", + "verify_authorization": "authorization_not_allowed" + }, + { + "file": "pq_pure/own_uppercase_hex.bin", + "now_ms": 1790363855311, + "own_namespace": "malformed", + "procedure": "~07A00CDA919D23FD9CBF8B4B39EED85FCBD72E3922FB2EADC34EAB99DE71338C/ring", + "profile": "pq_pure", + "verify_authorization": "malformed" + }, + { + "file": "pq_pure/own_short_hex.bin", + "now_ms": 1790363855313, + "own_namespace": "malformed", + "procedure": "~07a00cda919d23fd9cbf8b4b39eed85fcbd72e3922fb2eadc34eab99de7133/ring", + "profile": "pq_pure", + "verify_authorization": "malformed" + }, + { + "file": "pq_pure/org_without_chain.bin", + "now_ms": 1790363855314, + "own_namespace": "not_own_namespace", + "procedure": "acme/ring", + "profile": "pq_pure", + "verify_authorization": "no_authorization" + }, + { + "file": "pq_pure/own_hex_without_a_name.bin", + "now_ms": 1790363855315, + "own_namespace": "not_own_namespace", + "procedure": "~07a00cda919d23fd9cbf8b4b39eed85fcbd72e3922fb2eadc34eab99de71338c", + "profile": "pq_pure", + "verify_authorization": "ok" + }, + { + "file": "pq_hybrid/own_ok.bin", + "now_ms": 1790363855662, + "own_namespace": "ok", + "procedure": "~a552e6b5f762128fd89a805bbe4d6d34a6bb4c5581834267a1929a958568e6f7/ring", + "profile": "pq_hybrid", + "verify_authorization": "ok" + }, + { + "file": "pq_hybrid/own_other_node.bin", + "now_ms": 1790363855672, + "own_namespace": "not_own_namespace", + "procedure": "~0000000000000000000000000000000000000000000000000000000000000001/ring", + "profile": "pq_hybrid", + "verify_authorization": "not_own_namespace" + }, + { + "file": "pq_hybrid/own_with_authorization.bin", + "now_ms": 1790363855678, + "own_namespace": "authorization_not_allowed", + "procedure": "~a552e6b5f762128fd89a805bbe4d6d34a6bb4c5581834267a1929a958568e6f7/ring", + "profile": "pq_hybrid", + "verify_authorization": "authorization_not_allowed" + }, + { + "file": "pq_hybrid/own_uppercase_hex.bin", + "now_ms": 1790363855685, + "own_namespace": "malformed", + "procedure": "~A552E6B5F762128FD89A805BBE4D6D34A6BB4C5581834267A1929A958568E6F7/ring", + "profile": "pq_hybrid", + "verify_authorization": "malformed" + }, + { + "file": "pq_hybrid/own_short_hex.bin", + "now_ms": 1790363855694, + "own_namespace": "malformed", + "procedure": "~a552e6b5f762128fd89a805bbe4d6d34a6bb4c5581834267a1929a958568e6/ring", + "profile": "pq_hybrid", + "verify_authorization": "malformed" + }, + { + "file": "pq_hybrid/org_without_chain.bin", + "now_ms": 1790363855701, + "own_namespace": "not_own_namespace", + "procedure": "acme/ring", + "profile": "pq_hybrid", + "verify_authorization": "no_authorization" + }, + { + "file": "pq_hybrid/own_hex_without_a_name.bin", + "now_ms": 1790363855708, + "own_namespace": "not_own_namespace", + "procedure": "~a552e6b5f762128fd89a805bbe4d6d34a6bb4c5581834267a1929a958568e6f7", + "profile": "pq_hybrid", + "verify_authorization": "ok" + } +] + From d43a8a6311b26af3ee9264cb1f49ab488965fe7d Mon Sep 17 00:00:00 2001 From: beamologist Date: Sat, 26 Sep 2026 08:08:09 +0200 Subject: [PATCH 02/12] identity: macula 12 node keys (ML-DSA-87, LAMPS composite), key files, bindings and signed objects; LAMPS vector recorded and cross-verified with macula 12.8.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 13 +- examples/cross_verify_sign.rs | 29 + scripts/cross-verify-macula.sh | 35 ++ scripts/cross-verify-macula/rebar.config | 2 + .../src/cross_verify_macula.app.src | 6 + .../src/cross_verify_macula.erl | 39 ++ src/binding.rs | 521 ++++++++++++++++++ src/cbor.rs | 2 +- src/lib.rs | 4 + src/node_key.rs | 349 ++++++++++++ src/node_key/der.rs | 66 +++ src/node_key/key_file.rs | 400 ++++++++++++++ src/profile.rs | 79 +++ src/signed_object.rs | 265 +++++++++ tests/identity_binding.rs | 327 +++++++++++ tests/identity_cross_verify.rs | 33 ++ tests/identity_key_file.rs | 194 +++++++ tests/identity_node_key.rs | 286 ++++++++++ tests/identity_signed_object.rs | 105 ++++ .../lamps_mldsa87_rsa4096_pss_sha512/ctx.bin | 1 + .../s_with_context.bin | Bin 0 -> 5139 bytes .../lamps_mldsa87_rsa4096_pss_sha512/sk.bin | Bin 0 -> 2380 bytes .../macula_12_cross/macula_signed/m.bin | 1 + .../macula_12_cross/macula_signed/pk.bin | Bin 0 -> 3118 bytes .../macula_12_cross/macula_signed/s.bin | Bin 0 -> 5139 bytes .../macula_12_cross/rust_signed/m.bin | 1 + .../macula_12_cross/rust_signed/pk.bin | Bin 0 -> 3118 bytes .../macula_12_cross/rust_signed/s.bin | Bin 0 -> 5139 bytes 28 files changed, 2756 insertions(+), 2 deletions(-) create mode 100644 examples/cross_verify_sign.rs create mode 100755 scripts/cross-verify-macula.sh create mode 100644 scripts/cross-verify-macula/rebar.config create mode 100644 scripts/cross-verify-macula/src/cross_verify_macula.app.src create mode 100644 scripts/cross-verify-macula/src/cross_verify_macula.erl create mode 100644 src/binding.rs create mode 100644 src/node_key.rs create mode 100644 src/node_key/der.rs create mode 100644 src/node_key/key_file.rs create mode 100644 src/profile.rs create mode 100644 src/signed_object.rs create mode 100644 tests/identity_binding.rs create mode 100644 tests/identity_cross_verify.rs create mode 100644 tests/identity_key_file.rs create mode 100644 tests/identity_node_key.rs create mode 100644 tests/identity_signed_object.rs create mode 100644 tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/ctx.bin create mode 100644 tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/s_with_context.bin create mode 100644 tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/sk.bin create mode 100644 tests/vectors/identity/macula_12_cross/macula_signed/m.bin create mode 100644 tests/vectors/identity/macula_12_cross/macula_signed/pk.bin create mode 100644 tests/vectors/identity/macula_12_cross/macula_signed/s.bin create mode 100644 tests/vectors/identity/macula_12_cross/rust_signed/m.bin create mode 100644 tests/vectors/identity/macula_12_cross/rust_signed/pk.bin create mode 100644 tests/vectors/identity/macula_12_cross/rust_signed/s.bin diff --git a/Cargo.toml b/Cargo.toml index c2f835f..8a8907b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,13 @@ quinn = "0.11" # configuration starts from macula-pqc's client_builder(), which offers # SecP384r1MLKEM1024 then SecP256r1MLKEM768 and nothing classical. So # rustls selects no crypto provider of its own here. See transport.rs. -macula-pqc = "0.1" +macula-pqc = "0.3" +# ML-DSA-87 for every node key signature (profile.rs, node_key.rs): the same +# implementation macula-pqc signs TLS with. +macula-mldsa = "0.3" +# The RSA-PSS-4096 half of pq_hybrid's LAMPS composite: already linked through +# rustls for the key exchange, constant-time, and with a FIPS path. +aws-lc-rs = "1" rustls = { version = "0.23", default-features = false, features = ["logging", "std", "tls12"] } webpki-roots = "1.0" x509-parser = "0.18" @@ -91,6 +97,11 @@ linux-keyutils-keyring-store = "1" [target.'cfg(target_os = "ios")'.dependencies] apple-native-keyring-store = { version = "1", features = ["protected"] } +[target.'cfg(unix)'.dependencies] +# node_key/key_file.rs: a key file must belong to the effective user, and is +# opened without blocking; std has neither geteuid nor O_NONBLOCK without libc. +rustix = { version = "1", features = ["fs", "process"] } + [dev-dependencies] hex = "0.4" tempfile = "3" diff --git a/examples/cross_verify_sign.rs b/examples/cross_verify_sign.rs new file mode 100644 index 0000000..851190b --- /dev/null +++ b/examples/cross_verify_sign.rs @@ -0,0 +1,29 @@ +//! This crate's half of scripts/cross-verify-macula.sh: a pq_hybrid key made +//! for the run and never saved signs a message; the message, the public key +//! as carried and the signature go to the directory named on the command line +//! for macula to verify. + +use macula_rust::node_key::{verify, NodeKey, Purpose}; +use macula_rust::profile::Profile; + +fn main() -> Result<(), Box> { + let dir = std::path::PathBuf::from( + std::env::args() + .nth(1) + .ok_or("usage: cross_verify_sign

")?, + ); + let key = NodeKey::generate(Purpose::Identity, Profile::PqHybrid)?; + let message = b"signed by macula-rust"; + let signature = key.sign(message)?; + if !verify(message, &signature, &key.public_key(), Profile::PqHybrid) { + return Err("macula-rust does not verify its own composite".into()); + } + std::fs::write(dir.join("m.bin"), message)?; + std::fs::write(dir.join("pk.bin"), key.public_key())?; + std::fs::write(dir.join("s.bin"), &signature)?; + println!( + "rust_signed: {}-byte composite by macula-rust written", + signature.len() + ); + Ok(()) +} diff --git a/scripts/cross-verify-macula.sh b/scripts/cross-verify-macula.sh new file mode 100755 index 0000000..192ec52 --- /dev/null +++ b/scripts/cross-verify-macula.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Cross-verifies pq_hybrid composites (the LAMPS id-MLDSA87-RSA4096-PSS-SHA512) +# both ways between this SDK and macula 12.x, and writes what crossed to +# tests/vectors/identity/macula_12_cross for tests/identity_cross_verify.rs to +# hold: +# +# 1. this SDK makes a pq_hybrid key and signs a message (rust_signed/); +# 2. macula, from hex, in the image macula's own CI runs in, verifies that +# signature (and refuses it altered), then makes a pq_hybrid key of its +# own and signs a message (macula_signed/); +# 3. this SDK verifies macula's signature: cargo test --test +# identity_cross_verify. +# +# Needs cargo and podman. MACULA_CI_IMAGE +# overrides the image; the default is the one macula v12.7.0's test job pins. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +IMAGE="${MACULA_CI_IMAGE:-ghcr.io/macula-io/macula-ci-otp@sha256:aff1d39bc4aa29d13044b90b38e9b7f4b757d50818cc11c5bb7e84cdbf82ac70}" +OUT="$ROOT/tests/vectors/identity/macula_12_cross" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/cross-verify-macula.XXXXXX")" +trap 'podman unshare rm -rf "$WORK" 2>/dev/null || rm -rf "$WORK"' EXIT + +mkdir -p "$OUT/rust_signed" "$OUT/macula_signed" +(cd "$ROOT" && cargo run --quiet --example cross_verify_sign -- "$OUT/rust_signed") + +cp -r "$ROOT/scripts/cross-verify-macula/." "$WORK/project" +cp -r "$OUT" "$WORK/cross" +podman run --rm --cpus=4 --memory=8g --user root \ + -v "$WORK:/w:Z" -w /w/project "$IMAGE" sh -euc ' + rebar3 compile >/dev/null + erl -noshell -pa _build/default/lib/*/ebin \ + -eval "cross_verify_macula:main(\"/w/cross\"), halt()."' +cp "$WORK/cross/macula_signed/"*.bin "$OUT/macula_signed/" + +cd "$ROOT" && cargo test --test identity_cross_verify diff --git a/scripts/cross-verify-macula/rebar.config b/scripts/cross-verify-macula/rebar.config new file mode 100644 index 0000000..29d4a77 --- /dev/null +++ b/scripts/cross-verify-macula/rebar.config @@ -0,0 +1,2 @@ +{erl_opts, [debug_info]}. +{deps, [{macula, "~> 12.7"}]}. diff --git a/scripts/cross-verify-macula/src/cross_verify_macula.app.src b/scripts/cross-verify-macula/src/cross_verify_macula.app.src new file mode 100644 index 0000000..25d6b59 --- /dev/null +++ b/scripts/cross-verify-macula/src/cross_verify_macula.app.src @@ -0,0 +1,6 @@ +{application, cross_verify_macula, [ + {description, "macula 12.x's half of macula-rust's LAMPS composite cross-verification"}, + {vsn, "1"}, + {applications, [kernel, stdlib, crypto, public_key]}, + {env, []} +]}. diff --git a/scripts/cross-verify-macula/src/cross_verify_macula.erl b/scripts/cross-verify-macula/src/cross_verify_macula.erl new file mode 100644 index 0000000..c697971 --- /dev/null +++ b/scripts/cross-verify-macula/src/cross_verify_macula.erl @@ -0,0 +1,39 @@ +%% macula 12.x's half of scripts/cross-verify-macula.sh: verifies the pq_hybrid +%% composite macula-rust signed, refuses it altered, then signs a message with a +%% pq_hybrid key of its own for macula-rust to verify. The key is generated here +%% and never saved; no macula application is started, so no key file is read. +-module(cross_verify_macula). +-export([main/1]). + +main(Dir) -> + {ok, _} = application:ensure_all_started(crypto), + ok = application:load(macula), + {ok, Vsn} = application:get_key(macula, vsn), + io:format("macula ~s on OTP ~s~n", [Vsn, otp_version()]), + [Message, Public, Signature] = [read(Dir, "rust_signed", N) || N <- ["m.bin", "pk.bin", "s.bin"]], + true = macula_node_keys:verify(Message, Signature, Public, pq_hybrid), + false = macula_node_keys:verify(Message, flipped(Signature, 4627 + 10), Public, pq_hybrid), + false = macula_node_keys:verify(<>, Signature, Public, pq_hybrid), + io:format("rust_signed: verified by macula ~s, refused altered~n", [Vsn]), + {ok, Key} = macula_node_keys:generate(identity, pq_hybrid), + Ours = iolist_to_binary(["signed by macula ", Vsn]), + OurPublic = macula_node_keys:public_key(Key), + OurSignature = macula_node_keys:sign(Ours, Key), + true = macula_node_keys:verify(Ours, OurSignature, OurPublic, pq_hybrid), + ok = filelib:ensure_path(filename:join(Dir, "macula_signed")), + [ok = file:write_file(filename:join([Dir, "macula_signed", N]), B) + || {N, B} <- [{"m.bin", Ours}, {"pk.bin", OurPublic}, {"s.bin", OurSignature}]], + io:format("macula_signed: ~b-byte composite by macula ~s written~n", [byte_size(OurSignature), Vsn]). + +read(Dir, Signer, Name) -> + {ok, Bin} = file:read_file(filename:join([Dir, Signer, Name])), + Bin. + +flipped(Bin, At) -> + <> = Bin, + <>. + +otp_version() -> + {ok, V} = file:read_file(filename:join([code:root_dir(), "releases", erlang:system_info(otp_release), + "OTP_VERSION"])), + string:trim(V). diff --git a/src/binding.rs b/src/binding.rs new file mode 100644 index 0000000..655ad76 --- /dev/null +++ b/src/binding.rs @@ -0,0 +1,521 @@ +//! TLS and CONNECT bindings and status statements, as macula_key_bindings and +//! macula-go make and check them. A binding ties a station's TLS leaf, or a +//! node's CONNECT key, to an identity key for up to 7 days; a status +//! statement keeps a binding in force for up to an hour. Each travels as +//! `{tbs, signature}`, the signature over its label, a zero byte and the tbs +//! bytes; a verifier checks the signature over the bytes it received first, +//! and only then decodes them. + +use std::fmt; + +use sha2::{Digest, Sha384}; + +use crate::cbor::{self, Value}; +use crate::node_key::{node_id_of, verify, KeyError, NodeKey}; +use crate::profile::Profile; + +const LABEL_BINDING_TLS: &str = "MACULA-PQ-BINDING-TLS-V1"; +const LABEL_BINDING_CONNECT: &str = "MACULA-PQ-BINDING-CONNECT-V1"; +const LABEL_STATUS: &str = "MACULA-PQ-STATUS-V1"; +const MAX_BINDING_MS: i64 = 7 * 24 * 60 * 60 * 1000; +const MAX_STATUS_MS: i64 = 60 * 60 * 1000; +const TOLERANCE_MS: i64 = 5 * 60 * 1000; +const MAX_PROTOCOL_INT: i64 = 1 << 53; + +const BINDING_FIELDS: [&str; 9] = [ + "label", + "node_id", + "use", + "subject_hash", + "binding_id", + "not_before", + "not_after", + "hash_alg", + "sig_alg", +]; +const STATUS_FIELDS: [&str; 6] = [ + "label", + "node_id", + "binding_hash", + "issued_at", + "expires_at", + "sig_alg", +]; + +/// What a binding binds to the identity key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BindingUse { + /// A station's TLS key, by the leaf certificate it presents. + Tls, + /// A CONNECT key. + Connect, +} + +impl BindingUse { + fn name(self) -> &'static str { + match self { + BindingUse::Tls => "tls", + BindingUse::Connect => "connect", + } + } + + fn label(self) -> &'static str { + match self { + BindingUse::Tls => LABEL_BINDING_TLS, + BindingUse::Connect => LABEL_BINDING_CONNECT, + } + } +} + +/// The refusals of the binding and status checks, named as macula names them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BindingError { + /// A signed structure of the wrong shape: not exactly `{tbs, signature}`, + /// or a tbs that the decoding rule refuses, with a key its structure does + /// not define, or a field of the wrong type, length or range. + Malformed, + /// A binding whose signature does not verify under its use's label. + BindingSignatureInvalid, + /// A binding whose label or use is another use's. + WrongUse, + /// A binding whose subject is not the leaf or CONNECT key it came with. + KeyMismatch, + /// A binding more than 5 minutes past its not_after. + Expired, + /// A binding more than 5 minutes before its not_before. + NotYetValid, + /// A binding or statement naming a node_id other than the identity key's. + NodeIdMismatch, + /// A status statement whose signature does not verify. + StatusSignatureInvalid, + /// A status statement for another binding. + StatusBindingMismatch, + /// A status statement more than 5 minutes past its expiry. + StatusExpired, + /// A status statement issued more than 5 minutes ahead. + StatusFutureDated, + /// A window a verifier would refuse: negative, backwards, at 2^53 or later, + /// or longer than 7 days for a binding and an hour for a statement. + ValidityWindow, + /// The signing key could not sign, or is not an identity key. + Key(KeyError), +} + +impl fmt::Display for BindingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BindingError::Malformed => f.write_str("malformed signed structure"), + BindingError::BindingSignatureInvalid => { + f.write_str("the binding's signature does not verify") + } + BindingError::WrongUse => f.write_str("the binding is for another use"), + BindingError::KeyMismatch => f.write_str("the binding binds another key"), + BindingError::Expired => f.write_str("the binding has expired"), + BindingError::NotYetValid => f.write_str("the binding is not valid yet"), + BindingError::NodeIdMismatch => f.write_str("the structure names another node_id"), + BindingError::StatusSignatureInvalid => { + f.write_str("the status statement's signature does not verify") + } + BindingError::StatusBindingMismatch => { + f.write_str("the status statement is for another binding") + } + BindingError::StatusExpired => f.write_str("the status statement has expired"), + BindingError::StatusFutureDated => { + f.write_str("the status statement is dated in the future") + } + BindingError::ValidityWindow => { + f.write_str("a validity period outside what a verifier accepts") + } + BindingError::Key(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for BindingError {} + +impl From for BindingError { + fn from(e: KeyError) -> Self { + BindingError::Key(e) + } +} + +/// A signed structure as it travels: tbs, the deterministic CBOR of its +/// fields, and a signature over its label, a zero byte and tbs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignedTbs { + pub tbs: Vec, + pub signature: Vec, +} + +impl SignedTbs { + /// The structure as the map `{tbs, signature}`. + pub fn to_value(&self) -> Value { + Value::Map(vec![ + (Value::text("tbs"), Value::Bytes(self.tbs.clone())), + ( + Value::text("signature"), + Value::Bytes(self.signature.clone()), + ), + ]) + } + + /// The structure in `value`, which must be a map of exactly `tbs` and + /// `signature`, both byte strings. + pub fn from_value(value: &Value) -> Result { + match value { + Value::Map(pairs) if pairs.len() == 2 => { + match (value.get("tbs"), value.get("signature")) { + (Some(Value::Bytes(tbs)), Some(Value::Bytes(signature))) => Ok(SignedTbs { + tbs: tbs.clone(), + signature: signature.clone(), + }), + _ => Err(BindingError::Malformed), + } + } + _ => Err(BindingError::Malformed), + } + } +} + +/// What a verified binding says. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BindingInfo { + pub use_: BindingUse, + pub node_id: [u8; 32], + pub not_after: i64, +} + +/// Binds the TLS key of the leaf certificate a listener presents to +/// `identity_key`, by the SHA-384 of the leaf's DER, from `not_before` to +/// `not_after` in milliseconds. +pub fn tls_binding( + identity_key: &NodeKey, + leaf_der: &[u8], + not_before: i64, + not_after: i64, +) -> Result { + issue_binding( + identity_key, + BindingUse::Tls, + &sha384(leaf_der), + not_before, + not_after, + ) +} + +/// Binds a CONNECT key, by the SHA-384 of the key as carried, to +/// `identity_key`, from `not_before` to `not_after` in milliseconds. +pub fn connect_binding( + identity_key: &NodeKey, + connect_key: &[u8], + not_before: i64, + not_after: i64, +) -> Result { + issue_binding( + identity_key, + BindingUse::Connect, + &sha384(connect_key), + not_before, + not_after, + ) +} + +fn issue_binding( + identity_key: &NodeKey, + use_: BindingUse, + subject_hash: &[u8; 48], + not_before: i64, + not_after: i64, +) -> Result { + let node_id = identity_key.node_id()?; + if !within_window(not_before, not_after, MAX_BINDING_MS) { + return Err(BindingError::ValidityWindow); + } + let mut binding_id = [0u8; 16]; + aws_lc_rs::rand::fill(&mut binding_id) + .map_err(|_| BindingError::Key(KeyError::RandomnessUnavailable))?; + let tbs = encode(Value::Map(vec![ + text("label", use_.label()), + bytes("node_id", &node_id), + text("use", use_.name()), + bytes("subject_hash", subject_hash), + bytes("binding_id", &binding_id), + int("not_before", not_before), + int("not_after", not_after), + text("hash_alg", "SHA-384"), + text("sig_alg", identity_key.profile().sig_alg()), + ])); + sign_tbs(identity_key, use_.label(), tbs) +} + +/// Keeps `binding` in force from `issued_at` to `expires_at` in milliseconds, +/// at most an hour, signed by `identity_key`. +pub fn status_statement( + identity_key: &NodeKey, + binding: &SignedTbs, + issued_at: i64, + expires_at: i64, +) -> Result { + let node_id = identity_key.node_id()?; + if !within_window(issued_at, expires_at, MAX_STATUS_MS) { + return Err(BindingError::ValidityWindow); + } + let tbs = encode(Value::Map(vec![ + text("label", LABEL_STATUS), + bytes("node_id", &node_id), + bytes("binding_hash", &sha384(&binding.tbs)), + int("issued_at", issued_at), + int("expires_at", expires_at), + text("sig_alg", identity_key.profile().sig_alg()), + ])); + sign_tbs(identity_key, LABEL_STATUS, tbs) +} + +/// Checks `binding` against the identity key as carried, under `profile`, and +/// the leaf certificate this connection presented, at `now_ms` with 5 minutes +/// of tolerance. +pub fn verify_tls_binding( + binding: &SignedTbs, + identity_key: &[u8], + profile: Profile, + leaf_der: &[u8], + now_ms: i64, +) -> Result { + verify_binding( + binding, + identity_key, + profile, + BindingUse::Tls, + &sha384(leaf_der), + now_ms, + ) +} + +/// Checks `binding` against the identity key as carried, under `profile`, and +/// the CONNECT key as carried, at `now_ms` with 5 minutes of tolerance. +pub fn verify_connect_binding( + binding: &SignedTbs, + identity_key: &[u8], + profile: Profile, + connect_key: &[u8], + now_ms: i64, +) -> Result { + verify_binding( + binding, + identity_key, + profile, + BindingUse::Connect, + &sha384(connect_key), + now_ms, + ) +} + +/// The signature over the tbs bytes as received first, then the tbs decoded, +/// then its fields in macula's order: shape, use, node_id, subject, +/// not_before, not_after. +fn verify_binding( + binding: &SignedTbs, + identity_key: &[u8], + profile: Profile, + use_: BindingUse, + subject_hash: &[u8; 48], + now_ms: i64, +) -> Result { + if !verify( + &labelled(use_.label(), &binding.tbs), + &binding.signature, + identity_key, + profile, + ) { + return Err(BindingError::BindingSignatureInvalid); + } + let fields = decode_tbs(&binding.tbs, &BINDING_FIELDS).ok_or(BindingError::Malformed)?; + let parsed = well_formed_binding(&fields, profile).ok_or(BindingError::Malformed)?; + if parsed.label != use_.label() || parsed.use_ != use_.name() { + return Err(BindingError::WrongUse); + } + if parsed.node_id != node_id_of(identity_key, profile) { + return Err(BindingError::NodeIdMismatch); + } + if &parsed.subject_hash != subject_hash { + return Err(BindingError::KeyMismatch); + } + if now_ms + TOLERANCE_MS < parsed.not_before { + return Err(BindingError::NotYetValid); + } + if now_ms - TOLERANCE_MS > parsed.not_after { + return Err(BindingError::Expired); + } + Ok(BindingInfo { + use_, + node_id: parsed.node_id, + not_after: parsed.not_after, + }) +} + +struct BindingTbs<'a> { + label: &'a str, + use_: &'a str, + node_id: [u8; 32], + subject_hash: [u8; 48], + not_before: i64, + not_after: i64, +} + +fn well_formed_binding<'a>(f: &'a Fields, profile: Profile) -> Option> { + let binding_id = field_bytes(f, "binding_id")?; + let not_before = protocol_int(f, "not_before")?; + let not_after = protocol_int(f, "not_after")?; + let well_formed = binding_id.len() == 16 + && within_window(not_before, not_after, MAX_BINDING_MS) + && field_text(f, "hash_alg")? == "SHA-384" + && field_text(f, "sig_alg")? == profile.sig_alg(); + well_formed.then_some(BindingTbs { + label: field_text(f, "label")?, + use_: field_text(f, "use")?, + node_id: field_array(f, "node_id")?, + subject_hash: field_array(f, "subject_hash")?, + not_before, + not_after, + }) +} + +/// Checks a status statement for the binding it came with, against the +/// identity key as carried, under `profile`, at `now_ms` with 5 minutes of +/// tolerance, and returns when the statement expires. It checks that the +/// statement names that binding, not the binding itself: a caller verifies +/// the binding too. +pub fn verify_status( + statement: &SignedTbs, + binding: &SignedTbs, + identity_key: &[u8], + profile: Profile, + now_ms: i64, +) -> Result { + if !verify( + &labelled(LABEL_STATUS, &statement.tbs), + &statement.signature, + identity_key, + profile, + ) { + return Err(BindingError::StatusSignatureInvalid); + } + let fields = decode_tbs(&statement.tbs, &STATUS_FIELDS).ok_or(BindingError::Malformed)?; + let issued_at = protocol_int(&fields, "issued_at").ok_or(BindingError::Malformed)?; + let expires_at = protocol_int(&fields, "expires_at").ok_or(BindingError::Malformed)?; + let node_id: [u8; 32] = field_array(&fields, "node_id").ok_or(BindingError::Malformed)?; + let binding_hash: [u8; 48] = + field_array(&fields, "binding_hash").ok_or(BindingError::Malformed)?; + let well_formed = field_text(&fields, "label") == Some(LABEL_STATUS) + && within_window(issued_at, expires_at, MAX_STATUS_MS) + && field_text(&fields, "sig_alg") == Some(profile.sig_alg()); + if !well_formed { + return Err(BindingError::Malformed); + } + if node_id != node_id_of(identity_key, profile) { + return Err(BindingError::NodeIdMismatch); + } + if binding_hash != sha384(&binding.tbs) { + return Err(BindingError::StatusBindingMismatch); + } + if issued_at > now_ms + TOLERANCE_MS { + return Err(BindingError::StatusFutureDated); + } + if now_ms - TOLERANCE_MS > expires_at { + return Err(BindingError::StatusExpired); + } + Ok(expires_at) +} + +/// A tbs's fields by name. +type Fields = std::collections::HashMap; + +/// `tbs` decoded under the decoding rule, when it is a map whose keys are +/// exactly `names`, all text. +fn decode_tbs(tbs: &[u8], names: &[&str]) -> Option { + let Value::Map(pairs) = cbor::decode(tbs).ok()? else { + return None; + }; + if pairs.len() != names.len() { + return None; + } + let mut fields = Fields::with_capacity(pairs.len()); + for (key, value) in pairs { + let Value::Text(name) = key else { + return None; + }; + fields.insert(name, value); + } + names + .iter() + .all(|n| fields.contains_key(*n)) + .then_some(fields) +} + +/// Whether `from` and `to` are a validity period a verifier accepts: `from` +/// at least 0, `to` no earlier than `from` and below 2^53, and at most `max` +/// apart. +fn within_window(from: i64, to: i64, max: i64) -> bool { + from >= 0 && from <= to && to < MAX_PROTOCOL_INT && to - from <= max +} + +fn protocol_int(f: &Fields, name: &str) -> Option { + match f.get(name)? { + Value::Int(n) => i64::try_from(*n).ok(), + _ => None, + } +} + +fn field_text<'a>(f: &'a Fields, name: &str) -> Option<&'a str> { + match f.get(name)? { + Value::Text(t) => Some(t), + _ => None, + } +} + +fn field_bytes<'a>(f: &'a Fields, name: &str) -> Option<&'a [u8]> { + match f.get(name)? { + Value::Bytes(b) => Some(b), + _ => None, + } +} + +fn field_array(f: &Fields, name: &str) -> Option<[u8; N]> { + field_bytes(f, name)?.try_into().ok() +} + +fn sign_tbs(key: &NodeKey, label: &str, tbs: Vec) -> Result { + let signature = key.sign(&labelled(label, &tbs))?; + Ok(SignedTbs { tbs, signature }) +} + +/// `label`, a zero byte and `tbs`: what a binding or status statement signs. +fn labelled(label: &str, tbs: &[u8]) -> Vec { + let mut out = Vec::with_capacity(label.len() + 1 + tbs.len()); + out.extend_from_slice(label.as_bytes()); + out.push(0); + out.extend_from_slice(tbs); + out +} + +fn sha384(bytes: &[u8]) -> [u8; 48] { + Sha384::digest(bytes).into() +} + +/// A map of text keys and protocol values always encodes: every integer here +/// is an i64. +fn encode(value: Value) -> Vec { + cbor::encode(&value).expect("text-keyed fields of i64 integers always encode") +} + +fn text(name: &str, value: &str) -> (Value, Value) { + (Value::text(name), Value::text(value)) +} + +fn bytes(name: &str, value: &[u8]) -> (Value, Value) { + (Value::text(name), Value::Bytes(value.to_vec())) +} + +fn int(name: &str, value: i64) -> (Value, Value) { + (Value::text(name), Value::Int(i128::from(value))) +} diff --git a/src/cbor.rs b/src/cbor.rs index 750ce93..5cdcf69 100644 --- a/src/cbor.rs +++ b/src/cbor.rs @@ -412,7 +412,7 @@ impl Decoder<'_> { self.count()?; Ok(Value::Null) } - 25 | 26 | 27 => { + 25..=27 => { let width = match ai { 25 => 2, 26 => 4, diff --git a/src/lib.rs b/src/lib.rs index fb1cd02..c4d6f93 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ //! work, not the ceiling on it — nothing below the eventual FFI binding //! layer is mobile-specific. +pub mod binding; pub mod bolt4; pub mod cbor; pub mod cert; @@ -19,9 +20,12 @@ pub mod frame; pub mod identity; pub mod keystore; pub mod manifest; +pub mod node_key; mod open_sessions; pub mod petname; pub mod pool; +pub mod profile; +pub mod signed_object; pub mod stream; pub mod transport; pub mod ucan; diff --git a/src/node_key.rs b/src/node_key.rs new file mode 100644 index 0000000..f850f76 --- /dev/null +++ b/src/node_key.rs @@ -0,0 +1,349 @@ +//! A macula 12 node's keys, as macula and macula-go hold them: ML-DSA-87 in +//! pq_pure, and in pq_hybrid the LAMPS composite id-MLDSA87-RSA4096-PSS-SHA512 +//! (draft-ietf-lamps-pq-composite-sigs), which signs with both halves and is +//! valid only when both verify. An identity key's node_id (D5) solves the +//! admission puzzle; a CONNECT key is bound to it (see `crate::binding`). +//! Keys are stored in macula's seed form, readable by their owner only (see +//! [`NodeKey::save`] and [`NodeKey::load`]). +//! +//! The ML-DSA-87 half is macula-mldsa, the implementation macula-pqc signs +//! TLS with, kept as its 32-byte seed. The RSA-PSS-4096 half is aws-lc-rs, +//! already linked through rustls: constant-time, with a FIPS path. + +mod der; +mod key_file; + +pub use key_file::KeyFileError; + +use std::fmt; + +use aws_lc_rs::rand::SystemRandom; +use aws_lc_rs::rsa::{KeyPair as RsaKeyPair, KeySize}; +use aws_lc_rs::signature::{ + KeyPair as _, UnparsedPublicKey, RSA_PSS_2048_8192_SHA384, RSA_PSS_SHA384, +}; +use macula_mldsa::{PrivateKey, Zeroizing, ML_DSA_87}; +use sha2::{Digest, Sha256, Sha512}; + +use crate::profile::Profile; + +/// How many leading zero bits an identity key's node_id has: a node generates +/// its identity key for it ([`NodeKey::generate_identity`]), and stations +/// check it. +pub const PUZZLE_DIFFICULTY: u32 = 8; + +const MLDSA_PUBLIC_KEY_SIZE: usize = 2592; +const MLDSA_SIGNATURE_SIZE: usize = 4627; +const RSA_MODULUS_BYTES: usize = 512; +const COMPOSITE_PREFIX: &[u8] = b"CompositeAlgorithmSignatures2025"; +const COMPOSITE_LABEL: &[u8] = b"COMPSIG-MLDSA87-RSA4096-PSS-SHA512"; +const NODE_ID_LABEL: &[u8] = b"MACULA-NODE-ID-V1"; +const KEY_ID_LABEL: &[u8] = b"MACULA-KEY-ID-V1"; + +/// What a node key is for. Each key serves exactly one purpose. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Purpose { + /// A node's identity key, the key its node_id derives from and that signs + /// its bindings, status statements and signed objects. + Identity, + /// A node's CONNECT key, bound to its identity key, which signs the proof + /// of each connection it makes. + Connect, +} + +impl Purpose { + /// The purpose's name. + pub fn name(self) -> &'static str { + match self { + Purpose::Identity => "identity", + Purpose::Connect => "connect", + } + } +} + +impl fmt::Display for Purpose { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +/// Why a key operation refused. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeyError { + /// A node_id asked of a key that is not an identity key. + NotAnIdentityKey, + /// A puzzle difficulty outside 0 to 256. + DifficultyOutOfRange(u32), + /// The operating system gave no randomness. + RandomnessUnavailable, + /// Generating a half failed. + Generate(&'static str), + /// Signing with a half failed. + Sign(&'static str), +} + +impl fmt::Display for KeyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + KeyError::NotAnIdentityKey => f.write_str("not an identity key"), + KeyError::DifficultyOutOfRange(d) => { + write!(f, "puzzle difficulty {d} is outside 0 to 256") + } + KeyError::RandomnessUnavailable => { + f.write_str("the operating system gave no randomness") + } + KeyError::Generate(half) => write!(f, "could not generate the {half} half"), + KeyError::Sign(half) => write!(f, "could not sign with the {half} half"), + } + } +} + +impl std::error::Error for KeyError {} + +/// The RSA-PSS-4096 half of a pq_hybrid key. +struct RsaHalf { + pair: RsaKeyPair, + /// The DER `RSAPublicKey`, as carried after the ML-DSA-87 key. + public_der: Vec, +} + +/// One of a node's keys in its profile: the ML-DSA-87 half, and in pq_hybrid +/// the RSA-PSS-4096 half. It signs as a whole, never with one half on its own. +/// Showing it gives its purpose, profile and key id, never a private half. +pub struct NodeKey { + purpose: Purpose, + profile: Profile, + mldsa_seed: Zeroizing<[u8; 32]>, + mldsa_public: Vec, + rsa: Option, +} + +impl NodeKey { + /// A new key for `purpose` in `profile`. + pub fn generate(purpose: Purpose, profile: Profile) -> Result { + let (mldsa_public, mldsa_seed) = + macula_mldsa::key_gen_seed(ML_DSA_87).map_err(|_| KeyError::RandomnessUnavailable)?; + let rsa = if profile.hybrid() { + let pair = RsaKeyPair::generate(KeySize::Rsa4096) + .map_err(|_| KeyError::Generate("RSA-4096"))?; + let public_der = pair.public_key().as_ref().to_vec(); + Some(RsaHalf { pair, public_der }) + } else { + None + }; + Ok(NodeKey { + purpose, + profile, + mldsa_seed, + mldsa_public, + rsa, + }) + } + + /// A new identity key in `profile` whose node_id starts with `difficulty` + /// zero bits, found in about 2^difficulty tries. Each try makes a new + /// ML-DSA-87 half; a pq_hybrid key keeps its RSA-PSS half, since the + /// node_id covers both. + pub fn generate_identity(profile: Profile, difficulty: u32) -> Result { + if difficulty > 256 { + return Err(KeyError::DifficultyOutOfRange(difficulty)); + } + let mut key = NodeKey::generate(Purpose::Identity, profile)?; + while !puzzle_solved(&node_id_of(&key.public_key(), profile), difficulty) { + let (public, seed) = macula_mldsa::key_gen_seed(ML_DSA_87) + .map_err(|_| KeyError::RandomnessUnavailable)?; + key.mldsa_public = public; + key.mldsa_seed = seed; + } + Ok(key) + } + + /// What the key is for. + pub fn purpose(&self) -> Purpose { + self.purpose + } + + /// The profile the key belongs to. + pub fn profile(&self) -> Profile { + self.profile + } + + /// The key as carried (D13): the 2,592-byte ML-DSA-87 key, followed in + /// pq_hybrid by the DER `RSAPublicKey`. + pub fn public_key(&self) -> Vec { + let mut carried = self.mldsa_public.clone(); + if let Some(rsa) = &self.rsa { + carried.extend_from_slice(&rsa.public_der); + } + carried + } + + /// The node_id of an identity key (D5). + pub fn node_id(&self) -> Result<[u8; 32], KeyError> { + match self.purpose { + Purpose::Identity => Ok(node_id_of(&self.public_key(), self.profile)), + Purpose::Connect => Err(KeyError::NotAnIdentityKey), + } + } + + /// The id that names the key in signed objects: an identity key's + /// node_id, and the key id of any other key. + pub fn key_id(&self) -> [u8; 32] { + match self.purpose { + Purpose::Identity => node_id_of(&self.public_key(), self.profile), + Purpose::Connect => key_id_of(&self.public_key(), self.profile), + } + } + + /// Signs `message`: with ML-DSA-87 alone in pq_pure, and in pq_hybrid with + /// the composite, where both halves sign the message representative, the + /// ML-DSA-87 half with the composite label as its context, and the + /// signature is the ML-DSA-87 signature followed by the RSA-PSS one. + /// ML-DSA-87 signs hedged and RSA-PSS salted, so each signature is new. + pub fn sign(&self, message: &[u8]) -> Result, KeyError> { + let seed = PrivateKey::Seed(&self.mldsa_seed); + let Some(rsa) = &self.rsa else { + return macula_mldsa::sign(ML_DSA_87, seed, message, &[]) + .map_err(|_| KeyError::Sign("ML-DSA-87")); + }; + let representative = composite_representative(message); + let mut signature = macula_mldsa::sign(ML_DSA_87, seed, &representative, COMPOSITE_LABEL) + .map_err(|_| KeyError::Sign("ML-DSA-87"))?; + let mut rsa_signature = vec![0u8; rsa.pair.public_modulus_len()]; + rsa.pair + .sign( + &RSA_PSS_SHA384, + &SystemRandom::new(), + &representative, + &mut rsa_signature, + ) + .map_err(|_| KeyError::Sign("RSA-PSS"))?; + signature.extend_from_slice(&rsa_signature); + Ok(signature) + } +} + +impl fmt::Display for NodeKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} {} key {}", + self.purpose, + self.profile, + hex_of(&self.key_id()) + ) + } +} + +impl fmt::Debug for NodeKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +/// Whether `signature` is valid over `message` for a key as carried, under +/// `profile`: an ML-DSA-87 signature in pq_pure, and in pq_hybrid a composite +/// whose two halves both verify, with the key in its one carried form. +/// Malformed input is refused, never panicked on. +pub fn verify(message: &[u8], signature: &[u8], carried_key: &[u8], profile: Profile) -> bool { + if !profile.hybrid() { + return signature.len() == MLDSA_SIGNATURE_SIZE + && carried_key.len() == MLDSA_PUBLIC_KEY_SIZE + && macula_mldsa::verify(ML_DSA_87, carried_key, message, signature, &[]) == Ok(true); + } + if signature.len() != signature_size(profile) || !carried_key_well_formed(carried_key, profile) + { + return false; + } + let representative = composite_representative(message); + let (mldsa_public, rsa_public) = carried_key.split_at(MLDSA_PUBLIC_KEY_SIZE); + let (mldsa_signature, rsa_signature) = signature.split_at(MLDSA_SIGNATURE_SIZE); + let mldsa_valid = macula_mldsa::verify( + ML_DSA_87, + mldsa_public, + &representative, + mldsa_signature, + COMPOSITE_LABEL, + ) == Ok(true); + let rsa_valid = UnparsedPublicKey::new(&RSA_PSS_2048_8192_SHA384, rsa_public) + .verify(&representative, rsa_signature) + .is_ok(); + mldsa_valid && rsa_valid +} + +/// Whether `key` is a key in its one carried form for `profile` (D13): exactly +/// 2,592 bytes in pq_pure, and in pq_hybrid the ML-DSA-87 key followed by a DER +/// `RSAPublicKey` that encodes back to the same bytes, with a 4,096-bit modulus +/// and exponent 65537. It says nothing about who holds the key. +pub fn carried_key_well_formed(key: &[u8], profile: Profile) -> bool { + if !profile.hybrid() { + return key.len() == MLDSA_PUBLIC_KEY_SIZE; + } + if key.len() <= MLDSA_PUBLIC_KEY_SIZE { + return false; + } + let der = &key[MLDSA_PUBLIC_KEY_SIZE..]; + der::rsa_public_key_is_4096_f4(der) + && aws_lc_rs::rsa::PublicKey::from_der(der).is_ok_and(|parsed| parsed.as_ref() == der) +} + +/// The size of a signature by a node key in `profile`: the ML-DSA-87 +/// signature, followed in pq_hybrid by an RSA-PSS signature as long as the +/// modulus. +pub fn signature_size(profile: Profile) -> usize { + if profile.hybrid() { + MLDSA_SIGNATURE_SIZE + RSA_MODULUS_BYTES + } else { + MLDSA_SIGNATURE_SIZE + } +} + +/// The node_id of an identity key as carried, under `profile` (D5). A node_id +/// earns no trust on its own: rely on it only after a signature by the same +/// carried key has verified. +pub fn node_id_of(carried_key: &[u8], profile: Profile) -> [u8; 32] { + labelled_id(NODE_ID_LABEL, carried_key, profile) +} + +/// The key id of a key as carried that is not an identity key. Like a +/// node_id, it earns no trust on its own. +pub fn key_id_of(carried_key: &[u8], profile: Profile) -> [u8; 32] { + labelled_id(KEY_ID_LABEL, carried_key, profile) +} + +/// SHA-256 over `label`, a zero byte, the length and ASCII name of +/// `profile`, and a key as carried. +fn labelled_id(label: &[u8], carried_key: &[u8], profile: Profile) -> [u8; 32] { + let name = profile.name(); + let mut h = Sha256::new(); + h.update(label); + h.update([0, name.len() as u8]); + h.update(name.as_bytes()); + h.update(carried_key); + h.finalize().into() +} + +/// Whether `node_id` starts with `difficulty` zero bits. A difficulty above +/// 256 is never solved. +pub fn puzzle_solved(node_id: &[u8; 32], difficulty: u32) -> bool { + if difficulty > 256 { + return false; + } + let (whole, rest) = ((difficulty / 8) as usize, difficulty % 8); + node_id[..whole].iter().all(|&b| b == 0) && (rest == 0 || node_id[whole] >> (8 - rest) == 0) +} + +/// The message both halves of a composite sign: the prefix, the label, a zero +/// byte for the empty application context, and the SHA-512 of the message. +fn composite_representative(message: &[u8]) -> Vec { + let mut out = Vec::with_capacity(COMPOSITE_PREFIX.len() + COMPOSITE_LABEL.len() + 1 + 64); + out.extend_from_slice(COMPOSITE_PREFIX); + out.extend_from_slice(COMPOSITE_LABEL); + out.push(0); + out.extend_from_slice(&Sha512::digest(message)); + out +} + +fn hex_of(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} diff --git a/src/node_key/der.rs b/src/node_key/der.rs new file mode 100644 index 0000000..45d8813 --- /dev/null +++ b/src/node_key/der.rs @@ -0,0 +1,66 @@ +//! The little DER a node key reads: an `RSAPublicKey`'s modulus and exponent, +//! and the PKCS #1 key inside a PKCS #8 `PrivateKeyInfo`. Definite lengths in +//! their shortest form only, as DER has them; anything else is refused. + +const SEQUENCE: u8 = 0x30; +const INTEGER: u8 = 0x02; +const OCTET_STRING: u8 = 0x04; + +/// The element at the start of `input` with `tag`: its content, and what +/// follows it. +fn element(input: &[u8], tag: u8) -> Option<(&[u8], &[u8])> { + let (&first, rest) = input.split_first()?; + if first != tag { + return None; + } + let (&len0, rest) = rest.split_first()?; + let (len, rest) = if len0 < 0x80 { + (len0 as usize, rest) + } else { + let count = (len0 & 0x7f) as usize; + if count == 0 || count > 4 || rest.len() < count || rest[0] == 0 { + return None; + } + let len = rest[..count] + .iter() + .fold(0usize, |n, &b| (n << 8) | b as usize); + if len < 0x80 { + return None; + } + (len, &rest[count..]) + }; + if rest.len() < len { + return None; + } + Some(rest.split_at(len)) +} + +/// Whether `der` is exactly an `RSAPublicKey` with a 4,096-bit modulus and the +/// exponent 65537, each a minimal positive INTEGER. +pub(super) fn rsa_public_key_is_4096_f4(der: &[u8]) -> bool { + let Some((body, [])) = element(der, SEQUENCE) else { + return false; + }; + let Some((modulus, rest)) = element(body, INTEGER) else { + return false; + }; + let Some((exponent, [])) = element(rest, INTEGER) else { + return false; + }; + modulus.len() == 513 + && modulus[0] == 0 + && modulus[1] & 0x80 != 0 + && exponent == [0x01, 0x00, 0x01] +} + +/// The PKCS #1 `RSAPrivateKey` a PKCS #8 `PrivateKeyInfo` holds. +pub(super) fn pkcs1_of_pkcs8(pkcs8: &[u8]) -> Option> { + let (body, rest) = element(pkcs8, SEQUENCE)?; + if !rest.is_empty() { + return None; + } + let (_version, rest) = element(body, INTEGER)?; + let (_algorithm, rest) = element(rest, SEQUENCE)?; + let (key, _attributes) = element(rest, OCTET_STRING)?; + Some(key.to_vec()) +} diff --git a/src/node_key/key_file.rs b/src/node_key/key_file.rs new file mode 100644 index 0000000..4c10000 --- /dev/null +++ b/src/node_key/key_file.rs @@ -0,0 +1,400 @@ +//! Key files in macula's seed form, as macula-go writes them: the magic, the +//! purpose, profile and half count, then each half as its algorithm tag and its +//! public and private keys, each length-prefixed in four big-endian bytes. An +//! ML-DSA-87 half keeps its 32-byte seed, and an RSA-PSS half its PKCS #1 key. +//! A key file is readable by its owner only. + +use std::fmt; +use std::io::{Read, Write}; +use std::path::Path; + +use aws_lc_rs::encoding::AsDer; +use aws_lc_rs::rsa::KeyPair as RsaKeyPair; +use aws_lc_rs::signature::KeyPair as _; +use macula_mldsa::{PrivateKey, Zeroizing, ML_DSA_87}; + +use super::{der, verify, NodeKey, Purpose, RsaHalf}; +use crate::profile::Profile; + +/// Opens every key file this crate writes. macula's own key files hold the +/// expanded ML-DSA-87 key and open with "macula-node-key-v1", so one is never +/// taken for the other. +const MAGIC: &[u8] = b"macula-node-key-seed-v1\0"; + +/// The most a load reads: a key file is a few KiB. +const MAX_KEY_FILE_BYTES: u64 = 64 * 1024; + +const TAG_MLDSA_SEED: u8 = 1; +const TAG_RSA_PSS: u8 = 2; + +/// Why a key file was not saved or loaded. +#[derive(Debug)] +pub enum KeyFileError { + /// Reading or writing the file failed. + Io(std::io::Error), + /// The path names something other than a regular file, directly or + /// through a symlink. + NotRegular, + /// Another user than the effective user owns the file. + Owner, + /// The file's group or others can read it. + Permissions, + /// The file is longer than 64 KiB, which no key file is. + TooLarge, + /// The file is not a key file in the seed form. + BadKeyFile, + /// The file holds a key for another purpose, named here. + WrongPurpose(Purpose), + /// The file holds a key for another profile, named here. + WrongProfile(Profile), + /// The key's halves do not fit its profile. + WrongAlgorithms, + /// The RSA-PSS half is not a 4,096-bit key with exponent 65537. + WrongKeySize, + /// A private key does not decode. + PrivateKeyInvalid, + /// A stored public key is not the one its private key derives. + PublicKeyMismatch, + /// The key does not sign and verify as a whole. + RoundTripFailed, +} + +impl fmt::Display for KeyFileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + KeyFileError::Io(e) => write!(f, "key file: {e}"), + KeyFileError::NotRegular => f.write_str("the key file is not a regular file"), + KeyFileError::Owner => f.write_str("the key file is owned by another user"), + KeyFileError::Permissions => { + f.write_str("the key file can be read by its group or others") + } + KeyFileError::TooLarge => f.write_str("the key file is longer than 64 KiB"), + KeyFileError::BadKeyFile => f.write_str("not a key file in the seed form"), + KeyFileError::WrongPurpose(p) => write!(f, "the key file holds a key for {p}"), + KeyFileError::WrongProfile(p) => write!(f, "the key file holds a key for {p}"), + KeyFileError::WrongAlgorithms => f.write_str("the key's halves do not fit its profile"), + KeyFileError::WrongKeySize => { + f.write_str("the RSA-PSS half is not a 4096-bit key with exponent 65537") + } + KeyFileError::PrivateKeyInvalid => { + f.write_str("the key file's private key is not valid") + } + KeyFileError::PublicKeyMismatch => { + f.write_str("the stored public key is not the one its private key derives") + } + KeyFileError::RoundTripFailed => f.write_str("the key does not sign and verify"), + } + } +} + +impl std::error::Error for KeyFileError {} + +impl From for KeyFileError { + fn from(e: std::io::Error) -> Self { + KeyFileError::Io(e) + } +} + +impl NodeKey { + /// Writes the key to `path` in the seed form, readable by its owner only. + /// The file is created in a new owner-only directory beside `path`, + /// written, synced and renamed over any file at `path`; then `path`'s + /// directory is synced and the new one removed. Nothing else in the + /// directory is read, written or removed. + pub fn save(&self, path: &Path) -> Result<(), KeyFileError> { + let dir = match path.parent() { + Some(d) if !d.as_os_str().is_empty() => d, + _ => Path::new("."), + }; + create_dir_owner_only(dir, true)?; + let base = path + .file_name() + .ok_or(KeyFileError::NotRegular)? + .to_string_lossy(); + let staging = dir.join(format!(".{base}.saving-{}", random_suffix()?)); + create_dir_owner_only(&staging, false)?; + let result = write_staged(&staging, path, &self.file_bytes()?).and_then(|()| sync_dir(dir)); + let removed = std::fs::remove_dir_all(&staging); + result?; + removed.map_err(KeyFileError::from) + } + + /// The key saved at `path` for `purpose` in `profile`, checked before it + /// is returned. A path that names anything but a regular file, directly + /// or through a symlink, is refused before it is opened, and the opened + /// file is checked again: a regular file, owned by the effective user, + /// that its group and others cannot read, of at most 64 KiB. Then a key + /// for another purpose or profile, halves that do not fit the profile, a + /// stored public key its private key does not derive, and a key that + /// fails a sign-and-verify round trip are refused. + pub fn load(path: &Path, purpose: Purpose, profile: Profile) -> Result { + let contents = read_key_file(path)?; + let key = parse(&contents, purpose, profile)?; + round_trip(&key)?; + Ok(key) + } + + /// The key laid out as a key file. + fn file_bytes(&self) -> Result, KeyFileError> { + let mut out = MAGIC.to_vec(); + out.extend([ + purpose_tag(self.purpose), + profile_tag(self.profile), + if self.rsa.is_some() { 2 } else { 1 }, + ]); + append_half( + &mut out, + TAG_MLDSA_SEED, + &self.mldsa_public, + &self.mldsa_seed[..], + ); + if let Some(rsa) = &self.rsa { + let private = rsa_private_pkcs1(&rsa.pair)?; + append_half(&mut out, TAG_RSA_PSS, &rsa.public_der, &private); + } + Ok(out) + } +} + +fn purpose_tag(purpose: Purpose) -> u8 { + match purpose { + Purpose::Identity => 1, + Purpose::Connect => 2, + } +} + +fn profile_tag(profile: Profile) -> u8 { + match profile { + Profile::PqPure => 1, + Profile::PqHybrid => 2, + } +} + +fn append_half(out: &mut Vec, tag: u8, public: &[u8], private: &[u8]) { + out.push(tag); + out.extend((public.len() as u32).to_be_bytes()); + out.extend_from_slice(public); + out.extend((private.len() as u32).to_be_bytes()); + out.extend_from_slice(private); +} + +/// The RSA half's private key as PKCS #1, which aws-lc-rs hands out inside a +/// PKCS #8 `PrivateKeyInfo`. +fn rsa_private_pkcs1(pair: &RsaKeyPair) -> Result>, KeyFileError> { + let pkcs8 = pair.as_der().map_err(|_| KeyFileError::PrivateKeyInvalid)?; + der::pkcs1_of_pkcs8(pkcs8.as_ref()) + .map(Zeroizing::new) + .ok_or(KeyFileError::PrivateKeyInvalid) +} + +/// One half as a key file holds it. +struct StoredHalf<'a> { + tag: u8, + public: &'a [u8], + private: &'a [u8], +} + +/// A key file's key, checked for `purpose` and `profile`. +fn parse(bytes: &[u8], purpose: Purpose, profile: Profile) -> Result { + let rest = bytes.strip_prefix(MAGIC).ok_or(KeyFileError::BadKeyFile)?; + let [purpose_byte, profile_byte, count, halves_bytes @ ..] = rest else { + return Err(KeyFileError::BadKeyFile); + }; + let stored_purpose = match purpose_byte { + 1 => Purpose::Identity, + 2 => Purpose::Connect, + _ => return Err(KeyFileError::BadKeyFile), + }; + let stored_profile = match profile_byte { + 1 => Profile::PqPure, + 2 => Profile::PqHybrid, + _ => return Err(KeyFileError::BadKeyFile), + }; + let halves = parse_halves(halves_bytes)?; + if halves.len() != *count as usize { + return Err(KeyFileError::BadKeyFile); + } + if stored_purpose != purpose { + return Err(KeyFileError::WrongPurpose(stored_purpose)); + } + if stored_profile != profile { + return Err(KeyFileError::WrongProfile(stored_profile)); + } + let fits = match profile { + Profile::PqPure => halves.len() == 1 && halves[0].tag == TAG_MLDSA_SEED, + Profile::PqHybrid => { + halves.len() == 2 && halves[0].tag == TAG_MLDSA_SEED && halves[1].tag == TAG_RSA_PSS + } + }; + if !fits { + return Err(KeyFileError::WrongAlgorithms); + } + let (mldsa_seed, mldsa_public) = mldsa_from_half(&halves[0])?; + let rsa = if profile.hybrid() { + Some(rsa_from_half(&halves[1])?) + } else { + None + }; + Ok(NodeKey { + purpose, + profile, + mldsa_seed, + mldsa_public, + rsa, + }) +} + +fn parse_halves(mut bytes: &[u8]) -> Result>, KeyFileError> { + let mut halves = Vec::new(); + while let Some((&tag, rest)) = bytes.split_first() { + if tag != TAG_MLDSA_SEED && tag != TAG_RSA_PSS { + return Err(KeyFileError::BadKeyFile); + } + let (public, rest) = length_prefixed(rest)?; + let (private, rest) = length_prefixed(rest)?; + halves.push(StoredHalf { + tag, + public, + private, + }); + bytes = rest; + } + Ok(halves) +} + +fn length_prefixed(bytes: &[u8]) -> Result<(&[u8], &[u8]), KeyFileError> { + let (len, rest) = bytes + .split_first_chunk::<4>() + .ok_or(KeyFileError::BadKeyFile)?; + let len = u32::from_be_bytes(*len) as usize; + if len > rest.len() { + return Err(KeyFileError::BadKeyFile); + } + Ok(rest.split_at(len)) +} + +fn mldsa_from_half(half: &StoredHalf<'_>) -> Result<(Zeroizing<[u8; 32]>, Vec), KeyFileError> { + let seed: [u8; 32] = half + .private + .try_into() + .map_err(|_| KeyFileError::PrivateKeyInvalid)?; + let seed = Zeroizing::new(seed); + let derived = macula_mldsa::public_key(ML_DSA_87, PrivateKey::Seed(&seed)) + .map_err(|_| KeyFileError::PrivateKeyInvalid)?; + if derived != half.public { + return Err(KeyFileError::PublicKeyMismatch); + } + Ok((seed, derived)) +} + +fn rsa_from_half(half: &StoredHalf<'_>) -> Result { + let pair = RsaKeyPair::from_der(half.private).map_err(|_| KeyFileError::PrivateKeyInvalid)?; + if pair.public_key().as_ref() != half.public { + return Err(KeyFileError::PublicKeyMismatch); + } + if !der::rsa_public_key_is_4096_f4(half.public) { + return Err(KeyFileError::WrongKeySize); + } + Ok(RsaHalf { + pair, + public_der: half.public.to_vec(), + }) +} + +/// Signs a random message with the whole key and verifies it. A hybrid key +/// signs its composite, never one half on its own. +fn round_trip(key: &NodeKey) -> Result<(), KeyFileError> { + let mut message = [0u8; 32]; + aws_lc_rs::rand::fill(&mut message).map_err(|_| KeyFileError::RoundTripFailed)?; + let signature = key + .sign(&message) + .map_err(|_| KeyFileError::RoundTripFailed)?; + if verify(&message, &signature, &key.public_key(), key.profile) { + Ok(()) + } else { + Err(KeyFileError::RoundTripFailed) + } +} + +fn random_suffix() -> Result { + let mut bytes = [0u8; 8]; + aws_lc_rs::rand::fill(&mut bytes) + .map_err(|_| KeyFileError::Io(std::io::Error::other("no randomness")))?; + Ok(bytes.iter().map(|b| format!("{b:02x}")).collect()) +} + +fn write_staged(staging: &Path, path: &Path, contents: &[u8]) -> Result<(), KeyFileError> { + let staged = staging.join("key"); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let mut file = options.open(&staged)?; + file.write_all(contents)?; + file.sync_all()?; + drop(file); + std::fs::rename(&staged, path)?; + Ok(()) +} + +fn create_dir_owner_only(dir: &Path, recursive: bool) -> Result<(), KeyFileError> { + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(recursive); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + builder.create(dir)?; + Ok(()) +} + +fn sync_dir(dir: &Path) -> Result<(), KeyFileError> { + #[cfg(unix)] + std::fs::File::open(dir)?.sync_all()?; + #[cfg(not(unix))] + let _ = dir; + Ok(()) +} + +/// The contents of the key file at `path`, read only once the path names a +/// regular file and the opened file passes [`owner_only`]. +fn read_key_file(path: &Path) -> Result, KeyFileError> { + if !std::fs::metadata(path)?.is_file() { + return Err(KeyFileError::NotRegular); + } + let mut options = std::fs::OpenOptions::new(); + options.read(true); + // Without waiting, should the path have become a FIFO since it was + // checked. + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::custom_flags( + &mut options, + rustix::fs::OFlags::NONBLOCK.bits() as i32, + ); + let file = options.open(path)?; + owner_only(&file.metadata()?)?; + let mut contents = Vec::new(); + file.take(MAX_KEY_FILE_BYTES + 1) + .read_to_end(&mut contents)?; + if contents.len() as u64 > MAX_KEY_FILE_BYTES { + return Err(KeyFileError::TooLarge); + } + Ok(contents) +} + +/// Refuses an opened key file that is not a regular file, not the effective +/// user's, or readable by its group or others. +fn owner_only(metadata: &std::fs::Metadata) -> Result<(), KeyFileError> { + if !metadata.is_file() { + return Err(KeyFileError::NotRegular); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.uid() != rustix::process::geteuid().as_raw() { + return Err(KeyFileError::Owner); + } + if metadata.mode() & 0o077 != 0 { + return Err(KeyFileError::Permissions); + } + } + Ok(()) +} diff --git a/src/profile.rs b/src/profile.rs new file mode 100644 index 0000000..68a698d --- /dev/null +++ b/src/profile.rs @@ -0,0 +1,79 @@ +//! The post-quantum crypto profile a node runs, the counterpart of macula's +//! `macula_crypto_profile` and macula-go's `profile`. A realm runs one profile +//! and every node in it is configured with that one: there is no default, no +//! negotiation and no classical fallback. + +use std::fmt; + +/// A crypto profile, by the name a node is configured with. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Profile { + /// The CNSA 2.0 profile: ML-DSA-87 signatures, with no classical half. + PqPure, + /// The hybrid profile, the fleet's: ML-DSA-87 alone in TLS, and every + /// other signature the LAMPS composite id-MLDSA87-RSA4096-PSS-SHA512, + /// valid only if both halves verify. + PqHybrid, +} + +/// A configured value that names no profile: empty, or not exactly one of +/// `pq_pure` and `pq_hybrid`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProfileError { + /// No profile is configured. + Missing, + /// The value is not exactly one known profile. + Unknown(String), +} + +impl fmt::Display for ProfileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ProfileError::Missing => f.write_str("no crypto profile is configured"), + ProfileError::Unknown(value) => write!(f, "not a known crypto profile: {value:?}"), + } + } +} + +impl std::error::Error for ProfileError {} + +impl Profile { + /// The profile `value` names, exactly. + pub fn parse(value: &str) -> Result { + match value { + "" => Err(ProfileError::Missing), + "pq_pure" => Ok(Profile::PqPure), + "pq_hybrid" => Ok(Profile::PqHybrid), + other => Err(ProfileError::Unknown(other.to_owned())), + } + } + + /// The name a node is configured with, and that node_ids are derived + /// over. + pub fn name(self) -> &'static str { + match self { + Profile::PqPure => "pq_pure", + Profile::PqHybrid => "pq_hybrid", + } + } + + /// Whether identity, CONNECT and status signatures pair ML-DSA-87 with + /// RSA-PSS-4096. + pub fn hybrid(self) -> bool { + self == Profile::PqHybrid + } + + /// The signature algorithm's name, as signed structures carry it. + pub fn sig_alg(self) -> &'static str { + match self { + Profile::PqPure => "ML-DSA-87", + Profile::PqHybrid => "ML-DSA-87-PS384", + } + } +} + +impl fmt::Display for Profile { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} diff --git a/src/signed_object.rs b/src/signed_object.rs new file mode 100644 index 0000000..3af3f94 --- /dev/null +++ b/src/signed_object.rs @@ -0,0 +1,265 @@ +//! Signed objects, as macula_signed_object and macula-go sign and verify them: +//! a record, a request, a reply, a relay error, a publication, or a stream +//! frame. The fields gain `alg`, the signer's profile algorithm, and are +//! encoded as tbs in the deterministic form; the signature covers the label, a +//! zero byte, the SHA-384 of the signer's key as carried, and tbs. An +//! [`Object`] carries its key; a [`HeldObject`] leaves it out for a verifier +//! that already holds it, and still signs its hash. + +use std::fmt; + +use sha2::{Digest, Sha384}; + +use crate::cbor::{self, Value}; +use crate::node_key::{carried_key_well_formed, verify, KeyError, NodeKey}; +use crate::profile::Profile; + +/// The refusals of a signed object, named as macula_signed_object names them, +/// and the ones signing gives. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ObjectError { + /// Not exactly the keys of its shape, each a byte string; a carried key + /// not in the verifier's profile's carried form; or a tbs the decoding + /// rule refuses, or that is not a map naming alg as text. + Malformed, + /// A signature that does not verify over the label, the key's hash and + /// the tbs as received. + SignatureInvalid, + /// An alg that names another profile's algorithm than the verifier's. + AlgMismatch, + /// A field to sign whose key is not text. + FieldKeyNotText, + /// Two fields to sign with one key, named here. + DuplicateField(String), + /// The key could not sign. + Key(KeyError), +} + +impl fmt::Display for ObjectError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ObjectError::Malformed => f.write_str("malformed signed object"), + ObjectError::SignatureInvalid => { + f.write_str("the signed object's signature does not verify") + } + ObjectError::AlgMismatch => { + f.write_str("the signed object names another profile's algorithm") + } + ObjectError::FieldKeyNotText => f.write_str("a field key that is not text"), + ObjectError::DuplicateField(name) => write!(f, "two fields named {name:?}"), + ObjectError::Key(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for ObjectError {} + +/// A signed object that carries its signer's key as carried. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Object { + pub key: Vec, + pub tbs: Vec, + pub signature: Vec, +} + +/// A signed object whose verifier already holds the signer's key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HeldObject { + pub tbs: Vec, + pub signature: Vec, +} + +/// A signed object that verified: the key it verified with, its tbs bytes as +/// received, and the map they decode to. +#[derive(Debug, Clone, PartialEq)] +pub struct VerifiedObject { + pub key: Vec, + pub tbs: Vec, + pub fields: Value, +} + +impl Object { + /// The object as the map `{key, tbs, signature}`. + pub fn to_value(&self) -> Value { + Value::Map(vec![ + (Value::text("key"), Value::Bytes(self.key.clone())), + (Value::text("tbs"), Value::Bytes(self.tbs.clone())), + ( + Value::text("signature"), + Value::Bytes(self.signature.clone()), + ), + ]) + } + + /// The object in `value`, a map of exactly `key`, `tbs` and `signature`, + /// each a byte string. + pub fn from_value(value: &Value) -> Result { + let [key, tbs, signature] = exact_byte_fields(value, ["key", "tbs", "signature"])?; + Ok(Object { + key, + tbs, + signature, + }) + } +} + +impl HeldObject { + /// The object as the map `{tbs, signature}`. + pub fn to_value(&self) -> Value { + Value::Map(vec![ + (Value::text("tbs"), Value::Bytes(self.tbs.clone())), + ( + Value::text("signature"), + Value::Bytes(self.signature.clone()), + ), + ]) + } + + /// The object in `value`, a map of exactly `tbs` and `signature`, each a + /// byte string. + pub fn from_value(value: &Value) -> Result { + let [tbs, signature] = exact_byte_fields(value, ["tbs", "signature"])?; + Ok(HeldObject { tbs, signature }) + } +} + +/// Signs `fields` under `label` with `key`. The fields gain alg, replacing any +/// alg they held; every field needs a text key of its own. +pub fn sign_object( + label: &str, + fields: &[(Value, Value)], + key: &NodeKey, +) -> Result { + let carried = key.public_key(); + let tbs = object_tbs(fields, key.profile())?; + let signature = key + .sign(&object_signed_bytes(label, &carried, &tbs)) + .map_err(ObjectError::Key)?; + Ok(Object { + key: carried, + tbs, + signature, + }) +} + +/// [`sign_object`] for a verifier that already holds `key`. +pub fn sign_held_object( + label: &str, + fields: &[(Value, Value)], + key: &NodeKey, +) -> Result { + let object = sign_object(label, fields, key)?; + Ok(HeldObject { + tbs: object.tbs, + signature: object.signature, + }) +} + +/// Verifies an object that carries its key, under `label` and the verifier's +/// `profile`, in macula's order: exactly key, tbs and signature, each a byte +/// string; key in the profile's carried form; the signature over tbs as +/// received; only then tbs under the decoding rule, a map whose alg names the +/// profile's algorithm. alg is checked and never selects an algorithm. +pub fn verify_object( + label: &str, + value: &Value, + profile: Profile, +) -> Result { + let object = Object::from_value(value)?; + if !carried_key_well_formed(&object.key, profile) { + return Err(ObjectError::Malformed); + } + verified(label, object.key, object.tbs, &object.signature, profile) +} + +/// Verifies an object whose key the verifier holds, as carried, under `label` +/// and the verifier's `profile`. +pub fn verify_held_object( + label: &str, + value: &Value, + key: &[u8], + profile: Profile, +) -> Result { + let held = HeldObject::from_value(value)?; + verified(label, key.to_vec(), held.tbs, &held.signature, profile) +} + +fn verified( + label: &str, + key: Vec, + tbs: Vec, + signature: &[u8], + profile: Profile, +) -> Result { + if !verify( + &object_signed_bytes(label, &key, &tbs), + signature, + &key, + profile, + ) { + return Err(ObjectError::SignatureInvalid); + } + let fields = cbor::decode(&tbs).map_err(|_| ObjectError::Malformed)?; + let alg = match (&fields, fields.get("alg")) { + (Value::Map(_), Some(Value::Text(alg))) => alg.clone(), + _ => return Err(ObjectError::Malformed), + }; + if alg != profile.sig_alg() { + return Err(ObjectError::AlgMismatch); + } + Ok(VerifiedObject { key, tbs, fields }) +} + +/// The fields with alg for `profile`, deterministically encoded. +fn object_tbs(fields: &[(Value, Value)], profile: Profile) -> Result, ObjectError> { + let mut seen = std::collections::HashSet::with_capacity(fields.len()); + let mut with_alg = Vec::with_capacity(fields.len() + 1); + for (key, value) in fields { + let Value::Text(name) = key else { + return Err(ObjectError::FieldKeyNotText); + }; + if seen.contains(name.as_str()) { + return Err(ObjectError::DuplicateField(name.clone())); + } + if name == "alg" { + continue; + } + seen.insert(name.as_str()); + with_alg.push((key.clone(), value.clone())); + } + with_alg.push((Value::text("alg"), Value::text(profile.sig_alg()))); + cbor::encode(&Value::Map(with_alg)).map_err(|_| ObjectError::Malformed) +} + +/// What a signed object's signature covers: label, a zero byte, the SHA-384 +/// of the key as carried, and tbs. +fn object_signed_bytes(label: &str, key: &[u8], tbs: &[u8]) -> Vec { + let mut out = Vec::with_capacity(label.len() + 1 + 48 + tbs.len()); + out.extend_from_slice(label.as_bytes()); + out.push(0); + out.extend_from_slice(&Sha384::digest(key)); + out.extend_from_slice(tbs); + out +} + +/// The byte strings under `names` in `value`, when it is a map of exactly +/// those text keys, each holding a byte string. +fn exact_byte_fields( + value: &Value, + names: [&str; N], +) -> Result<[Vec; N], ObjectError> { + let Value::Map(pairs) = value else { + return Err(ObjectError::Malformed); + }; + if pairs.len() != N { + return Err(ObjectError::Malformed); + } + let mut out: [Vec; N] = std::array::from_fn(|_| Vec::new()); + for (slot, name) in out.iter_mut().zip(names) { + match value.get(name) { + Some(Value::Bytes(b)) => *slot = b.clone(), + _ => return Err(ObjectError::Malformed), + } + } + Ok(out) +} diff --git a/tests/identity_binding.rs b/tests/identity_binding.rs new file mode 100644 index 0000000..446c442 --- /dev/null +++ b/tests/identity_binding.rs @@ -0,0 +1,327 @@ +//! TLS and CONNECT bindings and status statements, held to the ones macula +//! itself made (tests/vectors/identity/erlang_bindings.json, by +//! macula_key_bindings and macula_node_keys at macula v12.1.0): they verify +//! here as there, are refused where macula refuses them, and carry tbs bytes +//! this crate's encoder writes byte for byte. Then the ones this crate makes. + +use macula_rust::binding::{ + connect_binding, status_statement, tls_binding, verify_connect_binding, verify_status, + verify_tls_binding, BindingError, BindingUse, SignedTbs, +}; +use macula_rust::cbor; +use macula_rust::node_key::{node_id_of, NodeKey, Purpose}; +use macula_rust::profile::Profile; + +const DAY_MS: i64 = 24 * 60 * 60 * 1000; +const MINUTE_MS: i64 = 60 * 1000; +const MLDSA_SIGNATURE: usize = 4627; + +struct Entry { + profile: Profile, + now_ms: i64, + identity_key: Vec, + node_id: Vec, + leaf: Vec, + tls_binding: SignedTbs, + tls_status: SignedTbs, + connect_key: Vec, + connect_binding: SignedTbs, + connect_status: SignedTbs, +} + +fn entries() -> Vec { + let text = std::fs::read_to_string("tests/vectors/identity/erlang_bindings.json").unwrap(); + let doc: serde_json::Value = serde_json::from_str(&text).unwrap(); + let bytes = |v: &serde_json::Value| hex::decode(v.as_str().unwrap()).unwrap(); + let signed = |v: &serde_json::Value| SignedTbs { + tbs: bytes(&v["tbs"]), + signature: bytes(&v["signature"]), + }; + let entries: Vec = doc["entries"] + .as_array() + .unwrap() + .iter() + .map(|e| Entry { + profile: Profile::parse(e["profile"].as_str().unwrap()).unwrap(), + now_ms: e["now_ms"].as_i64().unwrap(), + identity_key: bytes(&e["identity_key"]), + node_id: bytes(&e["node_id"]), + leaf: bytes(&e["leaf"]), + tls_binding: signed(&e["tls_binding"]), + tls_status: signed(&e["tls_status"]), + connect_key: bytes(&e["connect_key"]), + connect_binding: signed(&e["connect_binding"]), + connect_status: signed(&e["connect_status"]), + }) + .collect(); + assert_eq!(entries.len(), 2, "one entry per profile"); + entries +} + +fn other(profile: Profile) -> Profile { + match profile { + Profile::PqPure => Profile::PqHybrid, + Profile::PqHybrid => Profile::PqPure, + } +} + +fn flipped(bytes: &[u8], at: usize) -> Vec { + let mut out = bytes.to_vec(); + out[at] ^= 1; + out +} + +#[test] +fn bindings_and_statements_macula_made_verify_here() { + for e in entries() { + let p = e.profile; + assert_eq!( + node_id_of(&e.identity_key, p).to_vec(), + e.node_id, + "{p:?}: node_id" + ); + for (name, tbs) in [ + ("TLS binding", &e.tls_binding.tbs), + ("TLS status", &e.tls_status.tbs), + ("CONNECT binding", &e.connect_binding.tbs), + ("CONNECT status", &e.connect_status.tbs), + ] { + let value = cbor::decode(tbs).unwrap(); + assert_eq!( + &cbor::encode(&value).unwrap(), + tbs, + "{p:?}: {name} re-encodes to macula's bytes" + ); + } + + let info = + verify_tls_binding(&e.tls_binding, &e.identity_key, p, &e.leaf, e.now_ms).unwrap(); + assert_eq!(info.use_, BindingUse::Tls); + assert_eq!(info.node_id.to_vec(), e.node_id); + assert_eq!(info.not_after, e.now_ms + 7 * DAY_MS); + verify_status(&e.tls_status, &e.tls_binding, &e.identity_key, p, e.now_ms).unwrap(); + verify_connect_binding( + &e.connect_binding, + &e.identity_key, + p, + &e.connect_key, + e.now_ms, + ) + .unwrap(); + verify_status( + &e.connect_status, + &e.connect_binding, + &e.identity_key, + p, + e.now_ms, + ) + .unwrap(); + + let mut other_leaf = e.leaf.clone(); + other_leaf.push(0); + assert_eq!( + verify_tls_binding(&e.tls_binding, &e.identity_key, p, &other_leaf, e.now_ms) + .unwrap_err(), + BindingError::KeyMismatch + ); + assert_eq!( + verify_tls_binding( + &e.connect_binding, + &e.identity_key, + p, + &e.connect_key, + e.now_ms + ) + .unwrap_err(), + BindingError::BindingSignatureInvalid + ); + assert_eq!( + verify_status( + &e.tls_status, + &e.connect_binding, + &e.identity_key, + p, + e.now_ms + ) + .unwrap_err(), + BindingError::StatusBindingMismatch + ); + assert_eq!( + verify_tls_binding(&e.tls_binding, &e.identity_key, other(p), &e.leaf, e.now_ms) + .unwrap_err(), + BindingError::BindingSignatureInvalid + ); + assert_eq!( + verify_tls_binding( + &e.tls_binding, + &e.identity_key, + p, + &e.leaf, + e.now_ms + 7 * DAY_MS + 6 * MINUTE_MS + ) + .unwrap_err(), + BindingError::Expired + ); + } +} + +#[test] +fn a_binding_or_statement_macula_made_altered_by_one_byte_is_refused() { + for e in entries() { + let p = e.profile; + let mut offsets = vec![10]; + if p == Profile::PqHybrid { + offsets.push(MLDSA_SIGNATURE + 10); + } + let mut alterations: Vec SignedTbs>> = + vec![Box::new(|s: &SignedTbs| SignedTbs { + tbs: flipped(&s.tbs, s.tbs.len() / 2), + signature: s.signature.clone(), + })]; + for offset in offsets { + alterations.push(Box::new(move |s: &SignedTbs| SignedTbs { + tbs: s.tbs.clone(), + signature: flipped(&s.signature, offset), + })); + } + for alter in &alterations { + assert_eq!( + verify_tls_binding( + &alter(&e.tls_binding), + &e.identity_key, + p, + &e.leaf, + e.now_ms + ) + .unwrap_err(), + BindingError::BindingSignatureInvalid + ); + assert_eq!( + verify_connect_binding( + &alter(&e.connect_binding), + &e.identity_key, + p, + &e.connect_key, + e.now_ms + ) + .unwrap_err(), + BindingError::BindingSignatureInvalid + ); + assert_eq!( + verify_status( + &alter(&e.tls_status), + &e.tls_binding, + &e.identity_key, + p, + e.now_ms + ) + .unwrap_err(), + BindingError::StatusSignatureInvalid + ); + } + } +} + +#[test] +fn bindings_and_statements_made_here_verify_and_hold_their_windows() { + let now = 1_789_000_000_000; + let identity = NodeKey::generate(Purpose::Identity, Profile::PqPure).unwrap(); + let carried = identity.public_key(); + let connect = NodeKey::generate(Purpose::Connect, Profile::PqPure).unwrap(); + + let tls = tls_binding(&identity, b"a leaf", now, now + 7 * DAY_MS).unwrap(); + assert_eq!( + verify_tls_binding(&tls, &carried, Profile::PqPure, b"a leaf", now) + .unwrap() + .node_id, + identity.node_id().unwrap() + ); + let bound = connect_binding(&identity, &connect.public_key(), now, now + DAY_MS).unwrap(); + verify_connect_binding( + &bound, + &carried, + Profile::PqPure, + &connect.public_key(), + now, + ) + .unwrap(); + let status = status_statement(&identity, &bound, now, now + 60 * MINUTE_MS).unwrap(); + assert_eq!( + verify_status(&status, &bound, &carried, Profile::PqPure, now).unwrap(), + now + 60 * MINUTE_MS + ); + + assert_eq!( + verify_tls_binding( + &tls, + &carried, + Profile::PqPure, + b"a leaf", + now - 6 * MINUTE_MS + ) + .unwrap_err(), + BindingError::NotYetValid + ); + assert_eq!( + verify_status( + &status, + &bound, + &carried, + Profile::PqPure, + now + 66 * MINUTE_MS + ) + .unwrap_err(), + BindingError::StatusExpired + ); + assert_eq!( + verify_status( + &status, + &bound, + &carried, + Profile::PqPure, + now - 6 * MINUTE_MS + ) + .unwrap_err(), + BindingError::StatusFutureDated + ); + + // A window a verifier would refuse is not issued: backwards, longer than + // 7 days for a binding or an hour for a statement, or negative. + assert_eq!( + tls_binding(&identity, b"a leaf", now, now - 1).unwrap_err(), + BindingError::ValidityWindow + ); + assert_eq!( + tls_binding(&identity, b"a leaf", now, now + 7 * DAY_MS + 1).unwrap_err(), + BindingError::ValidityWindow + ); + assert_eq!( + status_statement(&identity, &bound, now, now + 60 * MINUTE_MS + 1).unwrap_err(), + BindingError::ValidityWindow + ); + assert_eq!( + tls_binding(&identity, b"a leaf", -1, now).unwrap_err(), + BindingError::ValidityWindow + ); + + // A CONNECT key has no node_id to bind for. + assert!(tls_binding(&connect, b"a leaf", now, now + DAY_MS).is_err()); +} + +#[test] +fn a_signed_tbs_travels_as_exactly_tbs_and_signature() { + let s = SignedTbs { + tbs: vec![1], + signature: vec![2], + }; + assert_eq!(SignedTbs::from_value(&s.to_value()).unwrap(), s); + let extra = cbor::Value::Map(vec![ + (cbor::Value::text("tbs"), cbor::Value::Bytes(vec![1])), + (cbor::Value::text("signature"), cbor::Value::Bytes(vec![2])), + (cbor::Value::text("more"), cbor::Value::Null), + ]); + assert_eq!( + SignedTbs::from_value(&extra).unwrap_err(), + BindingError::Malformed + ); +} diff --git a/tests/identity_cross_verify.rs b/tests/identity_cross_verify.rs new file mode 100644 index 0000000..a736d0c --- /dev/null +++ b/tests/identity_cross_verify.rs @@ -0,0 +1,33 @@ +//! pq_hybrid composites that crossed both ways with macula 12.x +//! (tests/vectors/identity/macula_12_cross, written by +//! scripts/cross-verify-macula.sh): one macula signed with a key of its own, +//! which verifies here, and one this crate signed, which macula verified. + +use macula_rust::node_key::verify; +use macula_rust::profile::Profile; + +const MLDSA_SIGNATURE: usize = 4627; + +fn crossed(signer: &str) -> (Vec, Vec, Vec) { + let read = |name: &str| { + std::fs::read(format!( + "tests/vectors/identity/macula_12_cross/{signer}/{name}" + )) + .unwrap() + }; + (read("m.bin"), read("pk.bin"), read("s.bin")) +} + +#[test] +fn a_composite_macula_signed_verifies() { + let (m, pk, mut s) = crossed("macula_signed"); + assert!(verify(&m, &s, &pk, Profile::PqHybrid)); + s[MLDSA_SIGNATURE + 10] ^= 1; + assert!(!verify(&m, &s, &pk, Profile::PqHybrid)); +} + +#[test] +fn the_composite_macula_verified_verifies_here() { + let (m, pk, s) = crossed("rust_signed"); + assert!(verify(&m, &s, &pk, Profile::PqHybrid)); +} diff --git a/tests/identity_key_file.rs b/tests/identity_key_file.rs new file mode 100644 index 0000000..7ba4351 --- /dev/null +++ b/tests/identity_key_file.rs @@ -0,0 +1,194 @@ +//! Key files in macula's seed form, readable by their owner only: saved and +//! loaded back as the same key, the layout byte for byte, and every refusal a +//! loader owes, among them the LAMPS draft's own private key loading as a +//! pq_hybrid node key. + +#![cfg(unix)] + +use std::os::unix::fs::PermissionsExt; + +use macula_rust::node_key::{verify, KeyFileError, NodeKey, Purpose}; +use macula_rust::profile::Profile; + +const MAGIC: &[u8] = b"macula-node-key-seed-v1\0"; + +fn vector(name: &str) -> Vec { + std::fs::read(format!( + "tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/{name}" + )) + .unwrap() +} + +/// A key file written by hand, as its layout says: the magic, the purpose, +/// profile and half count, then each half's tag and its public and private +/// keys, four-byte big-endian length-prefixed. +fn key_file(purpose: u8, profile: u8, halves: &[(u8, &[u8], &[u8])]) -> Vec { + let mut out = MAGIC.to_vec(); + out.extend([purpose, profile, halves.len() as u8]); + for (tag, public, private) in halves { + out.push(*tag); + out.extend((public.len() as u32).to_be_bytes()); + out.extend(*public); + out.extend((private.len() as u32).to_be_bytes()); + out.extend(*private); + } + out +} + +fn write_owner_only(path: &std::path::Path, bytes: &[u8]) { + std::fs::write(path, bytes).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); +} + +#[test] +fn a_saved_key_loads_back_as_the_same_key_readable_by_its_owner_only() { + let dir = tempfile::tempdir().unwrap(); + for profile in [Profile::PqPure, Profile::PqHybrid] { + let key = NodeKey::generate(Purpose::Identity, profile).unwrap(); + let path = dir.path().join(format!("{}.key", profile.name())); + key.save(&path).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "{profile:?}"); + let loaded = NodeKey::load(&path, Purpose::Identity, profile).unwrap(); + assert_eq!(loaded.public_key(), key.public_key()); + assert_eq!(loaded.node_id().unwrap(), key.node_id().unwrap()); + let signature = loaded.sign(b"loaded").unwrap(); + assert!(verify(b"loaded", &signature, &key.public_key(), profile)); + assert_eq!( + std::fs::read_dir(dir.path()).unwrap().count(), + if profile == Profile::PqPure { 1 } else { 2 } + ); + } +} + +#[test] +fn the_draft_s_private_key_is_a_pq_hybrid_node_key() { + let dir = tempfile::tempdir().unwrap(); + let (sk, pk) = (vector("sk.bin"), vector("pk.bin")); + let path = dir.path().join("draft.key"); + write_owner_only( + &path, + &key_file( + 1, + 2, + &[(1, &pk[..2592], &sk[..32]), (2, &pk[2592..], &sk[32..])], + ), + ); + let key = NodeKey::load(&path, Purpose::Identity, Profile::PqHybrid).unwrap(); + assert_eq!(key.public_key(), pk); + let m = vector("m.bin"); + let signature = key.sign(&m).unwrap(); + assert_eq!(signature.len(), vector("s.bin").len()); + assert!(verify(&m, &signature, &pk, Profile::PqHybrid)); + + // Saved again, the key file is the same bytes: the seed form round-trips. + let again = dir.path().join("again.key"); + key.save(&again).unwrap(); + assert_eq!( + std::fs::read(&again).unwrap(), + std::fs::read(&path).unwrap() + ); +} + +#[test] +fn a_key_file_its_group_or_others_can_read_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("node.key"); + NodeKey::generate(Purpose::Identity, Profile::PqPure) + .unwrap() + .save(&path) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap(); + assert!(matches!( + NodeKey::load(&path, Purpose::Identity, Profile::PqPure), + Err(KeyFileError::Permissions) + )); +} + +#[test] +fn a_key_of_another_purpose_or_profile_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("node.key"); + NodeKey::generate(Purpose::Identity, Profile::PqPure) + .unwrap() + .save(&path) + .unwrap(); + assert!(matches!( + NodeKey::load(&path, Purpose::Connect, Profile::PqPure), + Err(KeyFileError::WrongPurpose(Purpose::Identity)) + )); + assert!(matches!( + NodeKey::load(&path, Purpose::Identity, Profile::PqHybrid), + Err(KeyFileError::WrongProfile(Profile::PqPure)) + )); +} + +#[test] +fn a_key_file_that_is_not_one_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let (sk, pk) = (vector("sk.bin"), vector("pk.bin")); + let cases: Vec<(&str, Vec)> = vec![ + ("no magic", b"not a key file".to_vec()), + ( + "halves missing", + key_file(1, 2, &[(1, &pk[..2592], &sk[..32])]), + ), + ("a trailing byte", { + let mut b = key_file(1, 1, &[(1, &pk[..2592], &sk[..32])]); + b.push(0); + b + }), + ( + "an unknown tag", + key_file(1, 1, &[(9, &pk[..2592], &sk[..32])]), + ), + ( + "an unknown profile", + key_file(1, 7, &[(1, &pk[..2592], &sk[..32])]), + ), + ]; + for (name, bytes) in cases { + let path = dir.path().join("bad.key"); + write_owner_only(&path, &bytes); + let result = NodeKey::load(&path, Purpose::Identity, Profile::PqHybrid); + assert!( + matches!( + result, + Err(KeyFileError::BadKeyFile) | Err(KeyFileError::WrongAlgorithms) + ), + "{name}: {result:?}" + ); + } +} + +#[test] +fn a_stored_public_key_that_is_not_the_private_key_s_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let (sk, pk) = (vector("sk.bin"), vector("pk.bin")); + let path = dir.path().join("mismatch.key"); + let wrong = { + let mut p = pk[..2592].to_vec(); + p[100] ^= 1; + p + }; + write_owner_only(&path, &key_file(1, 1, &[(1, &wrong, &sk[..32])])); + assert!(matches!( + NodeKey::load(&path, Purpose::Identity, Profile::PqPure), + Err(KeyFileError::PublicKeyMismatch) + )); +} + +#[test] +fn a_directory_or_an_oversized_file_is_refused() { + let dir = tempfile::tempdir().unwrap(); + assert!(matches!( + NodeKey::load(dir.path(), Purpose::Identity, Profile::PqPure), + Err(KeyFileError::NotRegular) + )); + let big = dir.path().join("big.key"); + write_owner_only(&big, &vec![0u8; 64 * 1024 + 1]); + assert!(matches!( + NodeKey::load(&big, Purpose::Identity, Profile::PqPure), + Err(KeyFileError::TooLarge) + )); +} diff --git a/tests/identity_node_key.rs b/tests/identity_node_key.rs new file mode 100644 index 0000000..d3cfa11 --- /dev/null +++ b/tests/identity_node_key.rs @@ -0,0 +1,286 @@ +//! A macula 12 node key, as macula-go and macula hold one: ML-DSA-87 in +//! pq_pure, the LAMPS composite id-MLDSA87-RSA4096-PSS-SHA512 in pq_hybrid, +//! node_ids and key ids, the admission puzzle, and signatures held to the LAMPS +//! draft's own vector and to one macula's OTP stack made. + +use macula_rust::node_key::{ + carried_key_well_formed, key_id_of, node_id_of, puzzle_solved, signature_size, verify, + KeyError, NodeKey, Purpose, PUZZLE_DIFFICULTY, +}; +use macula_rust::profile::Profile; +use sha2::{Digest, Sha256}; + +const MLDSA_PUBLIC: usize = 2592; +const MLDSA_SIGNATURE: usize = 4627; + +fn vector(name: &str) -> Vec { + std::fs::read(format!( + "tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/{name}" + )) + .unwrap() +} + +fn flipped(bytes: &[u8], at: usize) -> Vec { + let mut out = bytes.to_vec(); + out[at] ^= 1; + out +} + +#[test] +fn profiles_are_parsed_by_their_exact_names() { + assert_eq!(Profile::parse("pq_pure"), Ok(Profile::PqPure)); + assert_eq!(Profile::parse("pq_hybrid"), Ok(Profile::PqHybrid)); + assert!(Profile::parse("").is_err()); + assert!(Profile::parse("PQ_HYBRID").is_err()); + assert!(Profile::parse("classical").is_err()); + assert_eq!(Profile::PqPure.sig_alg(), "ML-DSA-87"); + assert_eq!(Profile::PqHybrid.sig_alg(), "ML-DSA-87-PS384"); + assert_eq!(signature_size(Profile::PqPure), MLDSA_SIGNATURE); + assert_eq!(signature_size(Profile::PqHybrid), MLDSA_SIGNATURE + 512); +} + +#[test] +fn a_pq_pure_key_is_ml_dsa_87_alone() { + let key = NodeKey::generate(Purpose::Identity, Profile::PqPure).unwrap(); + assert_eq!(key.public_key().len(), MLDSA_PUBLIC); + assert!(carried_key_well_formed(&key.public_key(), Profile::PqPure)); + let signature = key.sign(b"a fact").unwrap(); + assert_eq!(signature.len(), MLDSA_SIGNATURE); + assert!(verify( + b"a fact", + &signature, + &key.public_key(), + Profile::PqPure + )); + assert!(!verify( + b"a fact!", + &signature, + &key.public_key(), + Profile::PqPure + )); + assert!(!verify( + b"a fact", + &flipped(&signature, 10), + &key.public_key(), + Profile::PqPure + )); + assert!(!verify( + b"a fact", + &signature, + &key.public_key(), + Profile::PqHybrid + )); +} + +#[test] +fn a_pq_hybrid_key_signs_the_composite_and_both_halves_must_verify() { + let key = NodeKey::generate(Purpose::Identity, Profile::PqHybrid).unwrap(); + let public = key.public_key(); + assert!(public.len() > MLDSA_PUBLIC); + assert!(carried_key_well_formed(&public, Profile::PqHybrid)); + assert!(!carried_key_well_formed(&public, Profile::PqPure)); + let signature = key.sign(b"a fact").unwrap(); + assert_eq!(signature.len(), MLDSA_SIGNATURE + 512); + assert!(verify(b"a fact", &signature, &public, Profile::PqHybrid)); + assert!(!verify( + b"a fact", + &flipped(&signature, 10), + &public, + Profile::PqHybrid + )); + assert!(!verify( + b"a fact", + &flipped(&signature, MLDSA_SIGNATURE + 10), + &public, + Profile::PqHybrid + )); + assert!(!verify(b"a fact", &signature, &public, Profile::PqPure)); + assert!(!verify( + b"a fact", + &signature[..MLDSA_SIGNATURE], + &public, + Profile::PqHybrid + )); +} + +#[test] +fn the_lamps_draft_vector_verifies_and_every_alteration_is_refused() { + let (m, pk, s) = (vector("m.bin"), vector("pk.bin"), vector("s.bin")); + assert!(verify(&m, &s, &pk, Profile::PqHybrid)); + let mut longer = m.clone(); + longer.push(0); + assert!(!verify(&longer, &s, &pk, Profile::PqHybrid)); + assert!(!verify(&m, &flipped(&s, 10), &pk, Profile::PqHybrid)); + assert!(!verify( + &m, + &flipped(&s, MLDSA_SIGNATURE + 10), + &pk, + Profile::PqHybrid + )); + assert!(!verify(&m, &s, &pk, Profile::PqPure)); +} + +#[test] +fn a_composite_whose_rsa_half_lost_its_zero_byte_is_refused_by_its_length() { + let zero_dropped = vector("zero_dropped_sig.bin"); + assert_eq!(zero_dropped.len(), MLDSA_SIGNATURE + 511); + assert!(!verify( + &vector("m.bin"), + &zero_dropped, + &vector("pk.bin"), + Profile::PqHybrid + )); +} + +#[test] +fn a_composite_macula_s_otp_stack_made_verifies() { + let (m, pk, s) = ( + vector("otp_message.bin"), + vector("otp_pk.bin"), + vector("otp_sig.bin"), + ); + assert!(verify(&m, &s, &pk, Profile::PqHybrid)); + assert!(!verify( + &m, + &flipped(&s, MLDSA_SIGNATURE + 10), + &pk, + Profile::PqHybrid + )); +} + +#[test] +fn a_carried_hybrid_key_must_be_its_one_canonical_form() { + let pk = vector("pk.bin"); + assert!(carried_key_well_formed(&pk, Profile::PqHybrid)); + assert!(!carried_key_well_formed( + &pk[..MLDSA_PUBLIC], + Profile::PqHybrid + )); + assert!(!carried_key_well_formed( + &pk[..pk.len() - 1], + Profile::PqHybrid + )); + let mut trailing = pk.clone(); + trailing.push(0); + assert!(!carried_key_well_formed(&trailing, Profile::PqHybrid)); + assert!(!carried_key_well_formed(&pk, Profile::PqPure)); + assert!(carried_key_well_formed( + &pk[..MLDSA_PUBLIC], + Profile::PqPure + )); +} + +/// node_id and key id: SHA-256 over their label, a zero byte, the profile's +/// name with its length, and the key as carried (D5). +#[test] +fn node_ids_and_key_ids_are_labelled_hashes_of_the_carried_key() { + let pk = vector("pk.bin"); + for (profile, name) in [ + (Profile::PqPure, "pq_pure"), + (Profile::PqHybrid, "pq_hybrid"), + ] { + for (label, id) in [ + ("MACULA-NODE-ID-V1", node_id_of(&pk, profile)), + ("MACULA-KEY-ID-V1", key_id_of(&pk, profile)), + ] { + let mut h = Sha256::new(); + h.update(label.as_bytes()); + h.update([0, name.len() as u8]); + h.update(name.as_bytes()); + h.update(&pk); + assert_eq!(id.to_vec(), h.finalize().to_vec(), "{label} {name}"); + } + } +} + +#[test] +fn the_admission_puzzle_counts_leading_zero_bits() { + let mut id = [0xffu8; 32]; + assert!(puzzle_solved(&id, 0)); + assert!(!puzzle_solved(&id, 1)); + id[0] = 0; + assert!(puzzle_solved(&id, 8)); + assert!(!puzzle_solved(&id, 9)); + id[1] = 0x1f; + assert!(puzzle_solved(&id, 11)); + assert!(!puzzle_solved(&id, 12)); + assert!(puzzle_solved(&[0u8; 32], 256)); + assert!(!puzzle_solved(&[0u8; 32], 257)); + assert_eq!(PUZZLE_DIFFICULTY, 8); +} + +#[test] +fn an_identity_key_is_generated_for_the_puzzle_and_only_it_has_a_node_id() { + let identity = NodeKey::generate_identity(Profile::PqPure, PUZZLE_DIFFICULTY).unwrap(); + let node_id = identity.node_id().unwrap(); + assert!(puzzle_solved(&node_id, PUZZLE_DIFFICULTY)); + assert_eq!(node_id, node_id_of(&identity.public_key(), Profile::PqPure)); + assert_eq!(identity.key_id(), node_id); + + let connect = NodeKey::generate(Purpose::Connect, Profile::PqPure).unwrap(); + assert!(matches!(connect.node_id(), Err(KeyError::NotAnIdentityKey))); + assert_eq!( + connect.key_id(), + key_id_of(&connect.public_key(), Profile::PqPure) + ); + assert!(NodeKey::generate_identity(Profile::PqPure, 257).is_err()); +} + +#[test] +fn a_key_shows_its_purpose_profile_and_key_id_and_never_a_private_half() { + let key = NodeKey::generate(Purpose::Connect, Profile::PqPure).unwrap(); + let shown = format!("{key:?}"); + assert_eq!( + shown, + format!("connect pq_pure key {}", hex::encode(key.key_id())) + ); + assert_eq!(format!("{key}"), shown); +} + +/// The draft's bytes as macula v12.7.0 carries them, pinned by sha256 so a +/// drifted copy fails here rather than passing on bytes nobody else signed: +/// the same sums macula-go, macula-php and macula-ts pin. +#[test] +fn the_lamps_vector_is_the_bytes_macula_pins() { + let pinned = [ + ( + "m.bin", + "ef537f25c895bfa782526529a9b63d97aa631564d5d789c2b765448c8635fb6c", + ), + ( + "pk.bin", + "88560e139b35d0738857f9c8e29bbcfb108e3539bd2bf6f4994bb4b34beb019d", + ), + ( + "sk.bin", + "0d4c65edb8735b5b677ea88050662406c7affd8e29ae27184726822a5ca889ce", + ), + ( + "s.bin", + "95e17c93e9c1d6b5c3c4bae9d8687cd1606e232dca0af38e437e7e2e16894303", + ), + ( + "s_with_context.bin", + "7261d9aeaaee3eb2612bb868d00d8eb6e174717bc427e8e6fa24cb7a73dcdeec", + ), + ( + "zero_dropped_sig.bin", + "4e43a85def2b0acec014724d7d4b23ac86685ef35f28a9be85d9aff4a7cd30cd", + ), + ]; + for (name, sum) in pinned { + assert_eq!(hex::encode(Sha256::digest(vector(name))), sum, "{name}"); + } +} + +/// Every Macula object signs with the empty context, so the draft's +/// signature made with one is refused. +#[test] +fn the_draft_s_signature_made_with_a_context_is_refused() { + assert!(!verify( + &vector("m.bin"), + &vector("s_with_context.bin"), + &vector("pk.bin"), + Profile::PqHybrid + )); +} diff --git a/tests/identity_signed_object.rs b/tests/identity_signed_object.rs new file mode 100644 index 0000000..e3cf0ea --- /dev/null +++ b/tests/identity_signed_object.rs @@ -0,0 +1,105 @@ +//! Signed objects, as macula_signed_object signs and verifies them: fields +//! gain alg, the signature covers the label, the key's SHA-384 and the tbs as +//! received, and a verifier reads the object in macula's order. + +use macula_rust::cbor::Value; +use macula_rust::node_key::{NodeKey, Purpose}; +use macula_rust::profile::Profile; +use macula_rust::signed_object::{ + sign_held_object, sign_object, verify_held_object, verify_object, HeldObject, Object, + ObjectError, +}; + +fn fields() -> Vec<(Value, Value)> { + vec![ + (Value::text("procedure"), Value::text("acme/echo")), + (Value::text("seq"), Value::Int(7)), + ] +} + +#[test] +fn a_signed_object_verifies_under_its_label_and_names_its_algorithm() { + for profile in [Profile::PqPure, Profile::PqHybrid] { + let key = NodeKey::generate(Purpose::Identity, profile).unwrap(); + let object = sign_object("MACULA-TEST-V1", &fields(), &key).unwrap(); + assert_eq!(object.key, key.public_key()); + let verified = verify_object("MACULA-TEST-V1", &object.to_value(), profile).unwrap(); + assert_eq!(verified.key, key.public_key()); + assert_eq!(verified.tbs, object.tbs); + assert_eq!( + verified.fields.get("alg"), + Some(&Value::text(profile.sig_alg())) + ); + assert_eq!(verified.fields.get("seq"), Some(&Value::Int(7))); + + assert_eq!( + verify_object("MACULA-OTHER-V1", &object.to_value(), profile).unwrap_err(), + ObjectError::SignatureInvalid + ); + let mut altered = object.clone(); + altered.tbs[3] ^= 1; + assert_eq!( + verify_object("MACULA-TEST-V1", &altered.to_value(), profile).unwrap_err(), + ObjectError::SignatureInvalid + ); + } +} + +#[test] +fn an_alg_the_signer_supplied_is_replaced_by_its_profile_s() { + let key = NodeKey::generate(Purpose::Identity, Profile::PqPure).unwrap(); + let mut with_alg = fields(); + with_alg.push((Value::text("alg"), Value::text("RSA"))); + let object = sign_object("MACULA-TEST-V1", &with_alg, &key).unwrap(); + let verified = verify_object("MACULA-TEST-V1", &object.to_value(), Profile::PqPure).unwrap(); + assert_eq!(verified.fields.get("alg"), Some(&Value::text("ML-DSA-87"))); +} + +#[test] +fn a_held_object_leaves_its_key_out_and_still_signs_its_hash() { + let key = NodeKey::generate(Purpose::Identity, Profile::PqPure).unwrap(); + let held = sign_held_object("MACULA-TEST-V1", &fields(), &key).unwrap(); + let value = held.to_value(); + assert_eq!(value.get("key"), None); + verify_held_object("MACULA-TEST-V1", &value, &key.public_key(), Profile::PqPure).unwrap(); + let other = NodeKey::generate(Purpose::Identity, Profile::PqPure).unwrap(); + assert_eq!( + verify_held_object( + "MACULA-TEST-V1", + &value, + &other.public_key(), + Profile::PqPure + ) + .unwrap_err(), + ObjectError::SignatureInvalid + ); +} + +#[test] +fn an_object_of_the_wrong_shape_or_another_profile_is_refused() { + let key = NodeKey::generate(Purpose::Identity, Profile::PqPure).unwrap(); + let object = sign_object("MACULA-TEST-V1", &fields(), &key).unwrap(); + // The verifier's profile is pq_hybrid: a pq_pure key is not in its + // carried form. + assert_eq!( + verify_object("MACULA-TEST-V1", &object.to_value(), Profile::PqHybrid).unwrap_err(), + ObjectError::Malformed + ); + let missing = Value::Map(vec![(Value::text("tbs"), Value::Bytes(object.tbs.clone()))]); + assert_eq!( + Object::from_value(&missing).unwrap_err(), + ObjectError::Malformed + ); + assert_eq!( + HeldObject::from_value(&object.to_value()).unwrap_err(), + ObjectError::Malformed + ); + + let duplicate = vec![ + (Value::text("a"), Value::Int(1)), + (Value::text("a"), Value::Int(2)), + ]; + assert!(sign_object("MACULA-TEST-V1", &duplicate, &key).is_err()); + let int_key = vec![(Value::Int(1), Value::Int(1))]; + assert!(sign_object("MACULA-TEST-V1", &int_key, &key).is_err()); +} diff --git a/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/ctx.bin b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/ctx.bin new file mode 100644 index 0000000..3870f89 --- /dev/null +++ b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/ctx.bin @@ -0,0 +1 @@ +The lethargic, colorless dog sat beneath the energetic, stationary fox. \ No newline at end of file diff --git a/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/s_with_context.bin b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/s_with_context.bin new file mode 100644 index 0000000000000000000000000000000000000000..b2854b4be27c54ab7014f68603a827692008ec79 GIT binary patch literal 5139 zcmV+u6zuCxgN%iaY${@+Oj6U=*uQK(b&9kUjE9OCkU@64@I7?g?T8@?i3M-Z-IL%x z#q^TRdd4knLqIqW)_4)UTkMLdARwfsk!=-t<+sJJ!qT7A+r}{PSkij)8vx(;#FtWi z;f55#@q*}Pi3BO3UQ9iB$D=K*wmH6e1@X$C>syJN1c4C&!p4 z29@zFWbo%S=mAc#WDAPPa~WWW!b-~xyF4rLo6^&Xp9u89L&!UHrCCXRvUVwUVyh5r zt{Sxd%}djz3`(Q1_s8i;oR z^|>^>b}QvhGk1pbKl{cqWQ_u`JlcEbR-7Inv=kM}GCJ_|heRLL(+{<5XLRW-=M7z& znlKyDvAFx)QNV|0XQ9u{{vii+Qx03+dyElSl-kp@ofSrM!oiKgQq?Wn%yLfswv#ZX z2mtL%lSOFd2s$fo94Q1Au>#&%EVmdI-WDrf8rIfvwO{Ja88_$dY`#2=miVS|vRhTa zg?cqjvS}8yD5qgV4-Brq#$ga*UqqQH{C8+|HjKL>JI$!k|3GkHq&@4xwzYpR5g3o^ zGU?`{qFzIqGa4vF-j&xf>La;Wv!8 zxxr&~ce-o$_x>(sRn4h3(;9$7832WhXvb$K17G@$PYa0e$H%8^!w=uGGdaVQQ0srO zkepx$SZuXv0HLZiY7|#sm`-+%s za$jM>1&!HGvb&5Z`3JhSq+o`Fq157`2@eVG%_?*UWhv(QpgakQ=)C?-Mk;^4x4xg~ zkiDQ1b}H_FR?|D+Hpw=E(ZH`Te%NJ;F^M?>$=oFIINAl=?33b;sz6YsPV;k81i6|K zB32q;tBY4Ve^%+gcMJRVz`nmF+$YFl-dFX0?aZ%bkp2Q0Jb$=`7&^sDUzh9iYPAv_ z=>5zg(QR~G#f(M#+c#5s^Z8wSBr)CkmlXrs5UpGqO93;}TddD{Set%B~SIT20UUIVk62^15wYFp`9R=QS>RLewG+Ti15+Ryb z&uZkbcuOS{sYdOWm_&m*;or$KDc5xvUg-i>S4MQ`#VP8YQXC<~r`=0qK0@sifb1jIr(CJQ6}a>=8_K#bckiy1O*6vJ+~&sN93aGetHK$?K92 zc?qY{+kh!dUcN-Xd9!^5-TxCnJCN^f-%yJ{ms!tBHx{h0{^6*b#ccV{cxwy#M2?JJ zRJ<$zzbfOlW9y55l!|q6Uw|wstubs5;}=Y0pZj_)Y}O2H#~apV_Q=ptnFL1Y9vm+*^CD=06wksn_IMMmYWI#4q@DAS`(8D)yrfDGE9J8kZLL z*Sj_^qX~;y#;!mnObg5=Y}0b)&`dZ1?JQs@XTBWS!qp_TI#_TSg+gAvFG#*U=`=bH zV*xT_lLy(>8z2Xij1@B|Q~TGJ(P}_xsysg8qYnY=bF<3#9wdv}X*{W{+=ai@Cz2A4 z`xmS*UFYw!#NfHYpmz+RIPL{!FP9F=&j$jHi^tb=qm!@L(P!dO1?}*)5VLrU&B}qC z-+#;FWNPm}bm*4zC&7~A58%uQM&HkNKANo#Wx|m(*RU3Th0=p^f>qa8vfLq}nqW7h zpa8s)=acl4z`<(IcUqxGZd#Z{EV((A)h{K}lB>2r zO?|az?;dN@ah3bNuv2m~SLgvL#0 z8#R??%8X6)F@at8GP^MEQJbTwrnuXaQEFWac`na;UrF6XIP-h0vkU8;UF;l>D6)}5 zPb^6&u3TkgsO?6r-W#Yy&d?G?gp500y3$YtYS_bK@uDD`c=E_T(>Ruh*_GJI%ZHGs zwqxD$R(L{zrxJJJ!%Z#uPI|HY5XzmPf-u@(O-=3_%x#zPk4j=Qz)fdR`NR6(T+~bx z$mjt6ko>{r&uQA3E3n5k7(~~l>TMZAJ}{jaA3VOfom=8dW1ZkCF$y`(N^aeH<3uiKxB&9AzeW>$2O8XJ8Q@XSE z
IXzmRulcU|R&>MyXkjVAld*uWomJp5nK+P#ukS7uw+nOP!El)3TAktT2ps9Mx zcQ*dq^lA|Tbfu59yL`q*SjMgEAruZy0G&l=MDlD|yIK~l zr2fgM6fM45R{Myk{|H}~Z=#&jz7iHSOd0g8^&dR4sY4L23lPPQA#nfs=z;oa{ciLq z)cpon`-MrU9S7He?^`TC*{~kChEuG|q>fnQA|VBSGw%{Wz=*(UA_bXaOYjl$oRBPz zv#`f|9BI{56$dsSYd+Hc6v&okE#T43yX^T$qRHFP5;px*<~R(}*VYIupp*2f;HiVu z903({l7YieGaALQ-|^1(1C%9TsTsIHjN=gq>@I1r$|wjWvm{XinMSwPp>{SYxdp(q zH(4I|)Dgrz<`FEvr!8gYCq{H1$qFEN>tF^3mf(jH1r_8Peg0Gu7*t@kF&l!PbAi*}>wRk1>K(a zp_JsmVrV=Y(2ll|>39gC1cbp#c}(+EC7sQyU!Jf`uti~qRu8|q?jQ0)#7^#%y&JR? zpz=5y(&d-a+sX|-O{mB$ZX55ui4@%?_s8|nwQ|re-gC$=L0&*F_!1+;F^Lq_Jj@>bB{F=4N!OL|1I(~WGtRCk}ygrC4l)0G1UB+9SG7c<^RVaL> z=*$nc67M1ac>@@+&^~!@2K4k`BrpSTd#A76ksg(NVALw3FF*}htxw*7t(M~_4XwFG z!)3K`;Inse2LI)5A^S^dI1a_(~>Ixgr$p4}y0 zlw0%ejbd@*oZsphk+C@xaW(kkc2bKh+o16={&ydEZLs!WeeDv7+m0%Es+72s zgAmT)qb_Y#rty`O|0?Txuld+Lwcuo~S;injfPU^;5L#PBue}ySti~@lO`bsCo(_|- zlhKFb)l4bp57b+$B@;TZwWmT(#g3y;as#qJEX9TP=U6Q~9G+m{YvAWzNguVxC3;Y_ z23aFOPOzLrsTYj%d#=H0^%|LXNFT%;W`5Yi2u;Riqp4a_;ngN%eepF9rcVlQV7Tkp zyRI!DZsCVRLTC%NSve_(Zf->BbdpXqRCN4628V zlom$c07t$xRt#s-59f&E>|7uHkC15pnALK#jCDLlUoG5_`Fa!VNe>lFL?_py$FKu_ zHF%6rgtdZS%(dRfgWGs!BHe*rt;dtv7Y%Wq=Pleup8#Nqv{K0Dc`FD#>8cSQcwFshVPu&IA@D?&hs1@i2@^mSlNngi23oXozjw%HJa7W9QcYF+wcNr*PvV>Gv zH-8D`40_H2(gFa$4Q7pV{VraaZN}8hU$gqHIDW3d?HStd3G60r2w?`>3--m~S;Q)Q z|L(gsMSWr@-Yg%xC&Pa;6w~yv&YJABJ1(WNuf5_KjGz6s*ViIHKw+`$b%m!CpgYhY z+ktqC1;VuVKkSXAxX${8mO}`wh|q_hxFYXhi4KM&;uBjFLb~(O(1{(@*#d&J8HfzL z%tZc)e|lB%PaQnJfYOcjsr9{i>a#lfemqs1g0r?gMHud zT|_3Cj{E=}zlE7{BpocXk^)|kUXPh$5`=dhI6-ZHxysBvLR4aapVQvq^8f$<000000000000000000OS z8677qHaZrF5a?)~fCQht*|k4Co;!?)9WJAuDbMA!A4(&YQXU8;l7y)W8H3&KH}@&t zwsnHoPOK96dlnCN^XsFPMgmejKZsJ6B4j)qMPSLc@LC_=G_xz>a7US-1ZMyx+%qMl zl@Cl2B@H#_v|aQhUyL$6pqLmHPTOuH)OB(1F%xM2R_3N#g_#j|J9JLEa*s}Tpt`jbh59jEu&FVsd!>a5U z4~IrooXUy^A5!nE(H@4L-b;E@HvqGdrah@@nXf9PKMiZEUxFRMK<+!=I54IYQ`tkW z+iqmcDd)^f6MyK`U!)H=HMH#2hi=y|oAR*hVu36eh8uxmAv@SafPSbQC^(sdNsTRQb!YG^ B3|asH literal 0 HcmV?d00001 diff --git a/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/sk.bin b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/sk.bin new file mode 100644 index 0000000000000000000000000000000000000000..bf6038d11cf0aabf0782458474f350e501150fa7 GIT binary patch literal 2380 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 literal 0 HcmV?d00001 diff --git a/tests/vectors/identity/macula_12_cross/macula_signed/m.bin b/tests/vectors/identity/macula_12_cross/macula_signed/m.bin new file mode 100644 index 0000000..1bf679b --- /dev/null +++ b/tests/vectors/identity/macula_12_cross/macula_signed/m.bin @@ -0,0 +1 @@ +signed by macula 12.8.0 \ No newline at end of file diff --git a/tests/vectors/identity/macula_12_cross/macula_signed/pk.bin b/tests/vectors/identity/macula_12_cross/macula_signed/pk.bin new file mode 100644 index 0000000000000000000000000000000000000000..592fbb37c1232e3f3c7b070ac82b6d0969024113 GIT binary patch literal 3118 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

nx&QzG literal 0 HcmV?d00001 diff --git a/tests/vectors/identity/macula_12_cross/macula_signed/s.bin b/tests/vectors/identity/macula_12_cross/macula_signed/s.bin new file mode 100644 index 0000000000000000000000000000000000000000..b084bfab52e234bcad105ff6491bc82e8b4be977 GIT binary patch literal 5139 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 B@yY-I literal 0 HcmV?d00001 diff --git a/tests/vectors/identity/macula_12_cross/rust_signed/m.bin b/tests/vectors/identity/macula_12_cross/rust_signed/m.bin new file mode 100644 index 0000000..277e8e2 --- /dev/null +++ b/tests/vectors/identity/macula_12_cross/rust_signed/m.bin @@ -0,0 +1 @@ +signed by macula-rust \ No newline at end of file diff --git a/tests/vectors/identity/macula_12_cross/rust_signed/pk.bin b/tests/vectors/identity/macula_12_cross/rust_signed/pk.bin new file mode 100644 index 0000000000000000000000000000000000000000..4f560a3612f645464a63a3efbc8065c17149c4b8 GIT binary patch literal 3118 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 I0s{d60RZL=_W%F@ literal 0 HcmV?d00001 diff --git a/tests/vectors/identity/macula_12_cross/rust_signed/s.bin b/tests/vectors/identity/macula_12_cross/rust_signed/s.bin new file mode 100644 index 0000000000000000000000000000000000000000..14424f16da62b0a3f79db5ecf1df4ccfbb26740f GIT binary patch literal 5139 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 Date: Sat, 26 Sep 2026 08:12:16 +0200 Subject: [PATCH 03/12] transport on macula-pqc 0.3 (key possession, with_initial); the 10.x stack removed; keystore holds a node key The 12 dial is macula-go's: TLS 1.3 with macula-pqc's post-quantum groups, one ML-DSA-87 station certificate whose key the station holds, a target that pins the node_id the handshake must prove, and quinn configured with the QUIC Initial suite. Tested over real QUIC against local stations, and refused for a classical certificate, classical groups, another ALPN, and no pinned node_id. The 10.x modules (connection, control channel, direct dial, frames, dht, streams, pool, content, manifest, ucan, bolt4, Ed25519 identity, X.509 trust) and their live tests and examples go: macula-pqc 0.3 verifies ML-DSA-87 stations only, and no 10.x station remains. keystore now keeps a node key's seed-form bytes, so a pq_hybrid key fits. macula-rust-ffi does not build until it moves to the 12 API. Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 58 +- examples/quickstart.rs | 123 - examples/ucan.rs | 113 - src/bolt4.rs | 154 -- src/cert.rs | 287 -- src/cert_chain.rs | 479 ---- src/connection.rs | 1666 ------------ src/content.rs | 398 --- src/control_channel.rs | 1668 ------------ src/control_channel/drop_warning.rs | 332 --- src/control_channel/fake_station.rs | 325 --- src/dht.rs | 829 ------ src/direct_dial.rs | 3786 -------------------------- src/frame.rs | 2806 ------------------- src/identity.rs | 451 --- src/keystore.rs | 117 +- src/lib.rs | 26 +- src/manifest.rs | 663 ----- src/node_key/key_file.rs | 26 + src/open_sessions.rs | 243 -- src/pool.rs | 1204 -------- src/stream.rs | 676 ----- src/transport.rs | 521 ++-- src/ucan.rs | 659 ----- tests/identity_binding.rs | 12 +- tests/identity_key_file.rs | 43 + tests/live_cert_chain.rs | 191 -- tests/live_direct_dial_extensions.rs | 264 -- tests/live_direct_dial_ucan.rs | 256 -- tests/live_pool.rs | 182 -- tests/live_station.rs | 2199 --------------- 31 files changed, 416 insertions(+), 20341 deletions(-) delete mode 100644 examples/quickstart.rs delete mode 100644 examples/ucan.rs delete mode 100644 src/bolt4.rs delete mode 100644 src/cert.rs delete mode 100644 src/cert_chain.rs delete mode 100644 src/connection.rs delete mode 100644 src/content.rs delete mode 100644 src/control_channel.rs delete mode 100644 src/control_channel/drop_warning.rs delete mode 100644 src/control_channel/fake_station.rs delete mode 100644 src/dht.rs delete mode 100644 src/direct_dial.rs delete mode 100644 src/frame.rs delete mode 100644 src/identity.rs delete mode 100644 src/manifest.rs delete mode 100644 src/open_sessions.rs delete mode 100644 src/pool.rs delete mode 100644 src/stream.rs delete mode 100644 src/ucan.rs delete mode 100644 tests/live_cert_chain.rs delete mode 100644 tests/live_direct_dial_extensions.rs delete mode 100644 tests/live_direct_dial_ucan.rs delete mode 100644 tests/live_pool.rs delete mode 100644 tests/live_station.rs diff --git a/Cargo.toml b/Cargo.toml index 8a8907b..b32e262 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,12 +7,12 @@ version = "0.4.0" edition = "2021" rust-version = "1.85" authors = ["Macula "] -description = "Rust port of macula's SDK (client/leaf) wire protocol — mobile first, not mobile-only. See plans/PLAN_WIRE_PROTOCOL.md." +description = "Rust SDK for the macula 12 mesh: ML-DSA-87 and LAMPS composite node keys, a post-quantum QUIC transport — mobile first, not mobile-only." license = "Apache-2.0" repository = "https://github.com/macula-io/macula-rust" homepage = "https://github.com/macula-io/macula-rust" readme = "README.md" -keywords = ["mesh-network", "quic", "p2p", "ed25519", "decentralized"] +keywords = ["mesh-network", "quic", "p2p", "post-quantum", "decentralized"] categories = ["network-programming", "cryptography"] [lints.rust] @@ -22,14 +22,13 @@ unsafe_code = "forbid" all = { level = "deny", priority = -1 } [dependencies] -ed25519-dalek = { version = "3.0", features = ["rand_core"] } -rand = "0.10" sha2 = "0.11" quinn = "0.11" # The key exchange for every connection this crate dials: every TLS # configuration starts from macula-pqc's client_builder(), which offers -# SecP384r1MLKEM1024 then SecP256r1MLKEM768 and nothing classical. So -# rustls selects no crypto provider of its own here. See transport.rs. +# SecP384r1MLKEM1024 then SecP256r1MLKEM768 and nothing classical, and whose +# KeyPossessionVerifier accepts one ML-DSA-87 station certificate. So rustls +# selects no crypto provider of its own here. See transport.rs. macula-pqc = "0.3" # ML-DSA-87 for every node key signature (profile.rs, node_key.rs): the same # implementation macula-pqc signs TLS with. @@ -37,28 +36,9 @@ macula-mldsa = "0.3" # The RSA-PSS-4096 half of pq_hybrid's LAMPS composite: already linked through # rustls for the key exchange, constant-time, and with a FIPS path. aws-lc-rs = "1" -rustls = { version = "0.23", default-features = false, features = ["logging", "std", "tls12"] } -webpki-roots = "1.0" -x509-parser = "0.18" -# cert_chain.rs: pure X.509 path validation (no hostname/SAN check, unlike -# rustls's own ServerCertVerifier machinery in cert.rs) to a caller-supplied -# realm CA — already transitively pulled in by rustls, declared directly -# here since cert_chain.rs uses its EndEntityCert::verify_for_usage API. -rustls-webpki = { version = "0.103", features = ["ring"] } +rustls = { version = "0.23", default-features = false, features = ["logging", "std"] } tokio = { version = "1", features = ["full"] } -uuid = { version = "1", features = ["v7"] } -# control_channel.rs: a session end and dropped unrouted frames are reported -# through the `log` facade, silent unless the application installs a logger. -# Already in the graph through rustls's `logging` feature. -log = "0.4" -blake3 = "1.5" -# ucan.rs: JWT-shaped token (de)serialization, matching the reference -# macula_ucan_nif's own dependency choices exactly (its Cargo.toml has no -# UCAN-spec crate either, only these same generic primitives). -serde = { version = "1", features = ["derive"] } -serde_json = "1" -base64 = "0.23" -# keystore.rs: platform-native secure storage for a persisted seed +# keystore.rs: platform-native secure storage for a node key # (Keychain via Security.framework on macOS/iOS, Secret Service via D-Bus # on Linux, Credential Manager on Windows, Keystore via JNI on Android — # each selected automatically per target by keyring's own Cargo.toml @@ -105,22 +85,10 @@ rustix = { version = "1", features = ["fs", "process"] } [dev-dependencies] hex = "0.4" tempfile = "3" -# Test-only: generates synthetic Ed25519 certs to unit-test -# PubkeyPinVerifier's matching logic without needing a live station that -# happens to present that cert type — see src/cert.rs's tests. Not a -# runtime dependency of the crate itself (see src/cert.rs's module doc: -# a dialing client never generates or presents a cert of its own). - -# Test-only, cert_chain.rs's own tests: signed_by() needs a SubjectPublicKeyInfo -# constructed from a raw advertiser Ed25519 pubkey rather than a freshly -# rcgen-generated one (the leaf must bind the SAME key that signed the -# advertisement) — SubjectPublicKeyInfo::from_der needs this feature. -rcgen = { version = "0.14", default-features = false, features = ["pem", "ring", "x509-parser"] } -# Test-only, transport.rs's own tests: the stations the dialler must still -# reach, or must refuse, are built from aws-lc-rs's own groups. +serde_json = "1" +# Test-only, transport.rs's own tests: a station with a classical certificate, +# which a dial must refuse. +rcgen = { version = "0.14", default-features = false, features = ["pem", "ring"] } +# Test-only, transport.rs's own tests: the stations the dialler must refuse +# are built from aws-lc-rs's own groups. rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] } -# Test-only, cert_chain.rs's own tests: rcgen's CertificateParams -# not_before/not_after fields take this crate's OffsetDateTime directly; -# not re-exported by rcgen, so declared explicitly (already transitively -# present via rcgen itself). -time = "0.3" diff --git a/examples/quickstart.rs b/examples/quickstart.rs deleted file mode 100644 index 932d3c5..0000000 --- a/examples/quickstart.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Minimal end-to-end example: connect to a station, advertise a -//! trivial echo procedure, and call it. Dials the real fleet, so this -//! isn't run by CI — see README.md's "Quick start" section, which this -//! file backs (kept compiling by `cargo build --examples` in CI, run -//! manually with `cargo run --example quickstart`). -//! -//! Two identities are used (a provider and a caller) because a station -//! kicks a connection the instant a second one arrives under the same -//! identity — the same reason this crate's own live tests use separate -//! identities for each role (see `tests/live_station.rs`'s -//! `unary_call_provider_round_trip_against_the_real_fleet`). The -//! procedure name is unique per run (a station's DHT can hold stale -//! routing state for a fixed name from a prior run's now-dead -//! advertiser) — and it's this crate's own procedure, not a shared -//! fleet service, so this example never depends on anything else being -//! deployed. -//! -//! The provider `Session` is moved back OUT of its `tokio::spawn` task -//! and closed explicitly, rather than let it drop when the task ends -- -//! see [`macula_rust::connection::Session`]'s own doc for why: dropping -//! the last handle closes the connection at once, which gives quinn's -//! send-scheduling no guarantee the RESULT this example just sent -//! actually reached the peer first. Confirmed live 2026-09-05: -//! under `#[tokio::main]`'s default multi-threaded runtime, a spawned -//! task with nothing after `serve_one_call().await` can complete (and -//! drop the session) within microseconds of the write, losing the reply -//! deterministically -- `tests/live_station.rs`'s own -//! `unary_call_provider_round_trip_multi_thread_runtime` reproduces this -//! and confirms the fix. -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use macula_rust::{ - cbor::Value, - connection::{self, BoxFuture, CallHandler}, - frame::AdvertiseSpec, - identity::KeyPair, - transport::Trust, -}; - -#[tokio::main] -async fn main() -> Result<(), Box> { - // Puzzle-hardened identities — required. An unhardened identity fails - // the handshake silently (QUIC/TLS looks healthy, HELLO never accepts). - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = connection::connect( - "station-de-frankfurt.macula.io", - 4433, - Trust::WebPki, - &provider_identity, - ) - .await?; - let caller_session = connection::connect( - "station-de-frankfurt.macula.io", - 4433, - Trust::WebPki, - &caller_identity, - ) - .await?; - - let realm = [0u8; 32]; - // Unique per run — reusing a fixed procedure name across rapid - // repeated runs can hit stale DHT routing state from the prior run's - // now-dead advertiser. - let procedure = format!( - "macula_rust.quickstart_echo.{}", - SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() - ); - - let advertise_spec = AdvertiseSpec::new(realm, procedure.clone(), provider_identity.node_id()); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await?; - tokio::time::sleep(Duration::from_millis(500)).await; // ADVERTISE is fire-and-forget; give it a moment to land - - let target_procedure = procedure.clone(); - let lookup = move |_realm: &[u8; 32], proc: &str| -> Option { - if proc != target_procedure { - return None; - } - let handler: CallHandler = std::sync::Arc::new(|payload: Value| { - Box::pin(async move { Ok(payload) }) as BoxFuture<'static, Result> - }); - Some(handler) - }; - - let serve_task = tokio::spawn(async move { - let result = provider_session - .serve_one_call(lookup, &provider_identity, Duration::from_secs(10)) - .await; - // Close explicitly instead of letting provider_session drop when - // this task ends -- see this file's own doc comment. - provider_session - .close( - "normal", - Some("quickstart provider done"), - &provider_identity, - ) - .await; - result - }); - - let now_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as i128; - let response = caller_session - .call( - &procedure, - realm, - Value::Text("hello".into()), - now_ms + 5_000, // deadline_ms - &caller_identity, - Duration::from_secs(5), - ) - .await?; - - serve_task.await??; - caller_session - .close("normal", Some("quickstart caller done"), &caller_identity) - .await; - - println!("{response:?}"); - Ok(()) -} diff --git a/examples/ucan.rs b/examples/ucan.rs deleted file mode 100644 index 09d0f7f..0000000 --- a/examples/ucan.rs +++ /dev/null @@ -1,113 +0,0 @@ -//! UCAN-gated serving: mint a token, gate a served procedure on it, show -//! both the rejected-without-token and accepted-with-token paths. Dials -//! the real fleet, so this isn't run by CI — see README.md's "Known -//! limitations" section for the investigation this example closes out -//! (kept compiling by `cargo build --examples` in CI, run manually with -//! `cargo run --example ucan`). -//! -//! Keeps the provider `Session` alive for a moment after -//! `serve_one_call_gated` returns before letting it drop — dropping its -//! last handle closes the QUIC connection at once, which can discard the -//! just-sent reply before it reaches the peer (the same race documented -//! on [`macula_rust::connection::Session::close`]). See this file's own -//! git history / README for the investigation that found this. -use std::sync::Arc; -use std::time::Duration; - -use macula_rust::{ - cbor::Value, connection, connection::CallHandler, identity::KeyPair, transport::Trust, ucan, -}; - -const HOST: &str = "station-de-frankfurt.macula.io"; -const PORT: u16 = 4433; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let provider_id = KeyPair::generate_with_default_puzzle(); - let caller_id = KeyPair::generate_with_default_puzzle(); - let authority = KeyPair::generate_with_default_puzzle(); - - let provider = connection::connect(HOST, PORT, Trust::WebPki, &provider_id).await?; - let caller = connection::connect(HOST, PORT, Trust::WebPki, &caller_id).await?; - - let realm = [0u8; 32]; - let procedure = "macula_rust.examples.ucan_gated"; - let advertise_spec = - macula_rust::frame::AdvertiseSpec::new(realm, procedure, provider_id.node_id()); - provider.advertise(&advertise_spec, &provider_id).await?; - tokio::time::sleep(Duration::from_millis(1200)).await; - - // Only callers holding a token issued by `authority` may invoke this - // procedure. A real deployment would use a stable, pre-shared - // authority identity, not one minted fresh per run. - let issuer_pub = authority.node_id(); - let handler: CallHandler = Arc::new(|payload: Value| { - Box::pin(async move { Ok(Value::Text(format!("granted: {payload:?}"))) }) - }); - - // serve_one_call_gated answers exactly ONE inbound call, then - // returns -- this example makes two calls (rejected, then granted), - // so the provider loops twice, once per expected call. - let serve_task = tokio::spawn(async move { - for _ in 0..2 { - let handler = handler.clone(); - provider - .serve_one_call_gated( - move |_realm, proc| { - if proc == procedure { - Some(handler.clone()) - } else { - None - } - }, - move |_, _| ucan::Policy::required(issuer_pub), - &provider_id, - Duration::from_secs(15), - ) - .await?; - } - // Keep the session alive briefly after the last reply -- see - // this file's module doc for why this matters. - tokio::time::sleep(Duration::from_millis(300)).await; - Ok::<(), connection::ServeCallError>(()) - }); - - // First call: no token at all -- refused before the handler ever runs. - let rejected = caller - .call( - procedure, - realm, - Value::Null, - 0, - &caller_id, - Duration::from_secs(5), - ) - .await; - println!("call without a token: {rejected:?}"); - - // Second call: a real token minted by the required authority. It names - // this caller as its audience (the caller's node id as lowercase hex), - // the only caller a gated provider accepts it from. - let token = ucan::create( - "did:key:example-issuer", - &hex::encode(caller_id.node_id()), - vec![], - &authority, - ucan::CreateOpts::default(), - )?; - let granted = caller - .call_with_ucan( - procedure, - realm, - Value::Text("hello".into()), - 0, - &caller_id, - Duration::from_secs(5), - token, - ) - .await; - println!("call with a valid token: {granted:?}"); - - serve_task.await??; - Ok(()) -} diff --git a/src/bolt4.rs b/src/bolt4.rs deleted file mode 100644 index d545a64..0000000 --- a/src/bolt4.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! BOLT#4-style error taxonomy for CALL failures, ported from -//! `src/peering/macula_bolt4.erl` (`macula-io/macula`) — see -//! `plans/PLAN_WIRE_PROTOCOL.md` §9. Adapted from Lightning Network's -//! BOLT#4 onion-failure codes: a small, specific taxonomy that prevents -//! retry loops and enables post-mortem, rather than an open-ended error -//! string. Codes are stable across V2 minor versions; new codes append -//! at the next free integer. -//! -//! The retry policy is advisory — a caller's own CALL state machine is -//! the actual decision point (not implemented by this module). - -/// The 17 codes macula's own `table/0` defines, in order. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Code { - Ok = 0x00, - UnknownNextPeer = 0x01, - TemporaryRelayFailure = 0x02, - RelayDisabled = 0x03, - NodeNotFoundAtTargetRelay = 0x04, - TargetRealmRefused = 0x05, - LoopDetected = 0x06, - ExpiryTooSoon = 0x07, - UpstreamCongestion = 0x08, - InvalidPathHeader = 0x09, - CryptoPuzzleInvalid = 0x0A, - RealmNotAuthoritativeHere = 0x0B, - Tombstoned = 0x0C, - PayloadTooLarge = 0x0D, - SignatureInvalid = 0x0E, - UnknownError = 0x0F, - /// Direct-dial dual-trust: the caller lacked a valid UCAN capability - /// for a gated procedure. - Unauthorized = 0x10, -} - -/// Whether the retry policy for a code permits retrying at all. `none` -/// (success), `application` (handler-level remedy), and `crypto_drop` -/// (security-critical) are all non-retryable — everything else means -/// "retry, differently." -impl Code { - pub fn as_u8(self) -> u8 { - self as u8 - } - - pub fn name(self) -> &'static str { - match self { - Code::Ok => "ok", - Code::UnknownNextPeer => "unknown_next_peer", - Code::TemporaryRelayFailure => "temporary_relay_failure", - Code::RelayDisabled => "relay_disabled", - Code::NodeNotFoundAtTargetRelay => "node_not_found_at_target_relay", - Code::TargetRealmRefused => "target_realm_refused", - Code::LoopDetected => "loop_detected", - Code::ExpiryTooSoon => "expiry_too_soon", - Code::UpstreamCongestion => "upstream_congestion", - Code::InvalidPathHeader => "invalid_path_header", - Code::CryptoPuzzleInvalid => "crypto_puzzle_invalid", - Code::RealmNotAuthoritativeHere => "realm_not_authoritative_here", - Code::Tombstoned => "tombstoned", - Code::PayloadTooLarge => "payload_too_large", - Code::SignatureInvalid => "signature_invalid", - Code::UnknownError => "unknown_error", - Code::Unauthorized => "unauthorized", - } - } - - pub fn is_retryable(self) -> bool { - !matches!( - self, - Code::Ok - | Code::TargetRealmRefused - | Code::Tombstoned - | Code::PayloadTooLarge - | Code::Unauthorized - | Code::CryptoPuzzleInvalid - | Code::SignatureInvalid - ) - } - - pub fn from_u8(code: u8) -> Option { - Some(match code { - 0x00 => Code::Ok, - 0x01 => Code::UnknownNextPeer, - 0x02 => Code::TemporaryRelayFailure, - 0x03 => Code::RelayDisabled, - 0x04 => Code::NodeNotFoundAtTargetRelay, - 0x05 => Code::TargetRealmRefused, - 0x06 => Code::LoopDetected, - 0x07 => Code::ExpiryTooSoon, - 0x08 => Code::UpstreamCongestion, - 0x09 => Code::InvalidPathHeader, - 0x0A => Code::CryptoPuzzleInvalid, - 0x0B => Code::RealmNotAuthoritativeHere, - 0x0C => Code::Tombstoned, - 0x0D => Code::PayloadTooLarge, - 0x0E => Code::SignatureInvalid, - 0x0F => Code::UnknownError, - 0x10 => Code::Unauthorized, - _ => return None, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn round_trips_every_defined_code() { - for code in 0x00u8..=0x10 { - let parsed = - Code::from_u8(code).unwrap_or_else(|| panic!("code {code:#x} should be defined")); - assert_eq!(parsed.as_u8(), code); - } - } - - #[test] - fn unknown_code_is_none() { - assert_eq!(Code::from_u8(0x11), None); - assert_eq!(Code::from_u8(0xFF), None); - } - - #[test] - fn non_retryable_codes_match_the_reference_table() { - // none | application | crypto_drop, per macula_bolt4.erl's table/0. - assert!(!Code::Ok.is_retryable()); - assert!(!Code::TargetRealmRefused.is_retryable()); - assert!(!Code::Tombstoned.is_retryable()); - assert!(!Code::PayloadTooLarge.is_retryable()); - assert!(!Code::Unauthorized.is_retryable()); - assert!(!Code::CryptoPuzzleInvalid.is_retryable()); - assert!(!Code::SignatureInvalid.is_retryable()); - } - - #[test] - fn retryable_codes_match_the_reference_table() { - assert!(Code::UnknownNextPeer.is_retryable()); - assert!(Code::TemporaryRelayFailure.is_retryable()); - assert!(Code::RelayDisabled.is_retryable()); - assert!(Code::NodeNotFoundAtTargetRelay.is_retryable()); - assert!(Code::LoopDetected.is_retryable()); - assert!(Code::ExpiryTooSoon.is_retryable()); - assert!(Code::UpstreamCongestion.is_retryable()); - assert!(Code::InvalidPathHeader.is_retryable()); - assert!(Code::RealmNotAuthoritativeHere.is_retryable()); - assert!(Code::UnknownError.is_retryable()); - } - - #[test] - fn names_match_the_reference_spelling() { - assert_eq!(Code::UnknownNextPeer.name(), "unknown_next_peer"); - assert_eq!(Code::Unauthorized.name(), "unauthorized"); - } -} diff --git a/src/cert.rs b/src/cert.rs deleted file mode 100644 index 54c9d13..0000000 --- a/src/cert.rs +++ /dev/null @@ -1,287 +0,0 @@ -//! TLS trust for dialing a macula-station: pubkey-pin verification, -//! ported from `native/macula_quic/src/cert.rs` (`macula-io/macula`). -//! -//! A station presents a self-signed X.509 cert wrapping its macula -//! identity's Ed25519 public key. A client that already knows which -//! station it's dialing (from DHT records, pre-shared relay identities, -//! or — for a mobile client — configuration) verifies by comparing the -//! cert's SubjectPublicKeyInfo to that known pubkey directly. No CA -//! chain, no DNS-anchored trust: the pubkey **is** the identity. -//! -//! Unlike the Erlang SDK's own `native/macula_quic`, this crate never -//! needs to *generate* a cert — macula uses `with_no_client_auth()` -//! throughout, so a dialing client presents no TLS certificate of its -//! own at all. Identity/authentication happens entirely at the -//! application layer (the CONNECT/HELLO frame's Ed25519 signature — see -//! `plans/PLAN_WIRE_PROTOCOL.md` §2, §5), not via mutual TLS. This -//! module is verification-only. - -use std::sync::Arc; - -use rustls::pki_types::CertificateDer; - -/// Extract the 32-byte Ed25519 public key from a DER-encoded X.509 -/// certificate's SubjectPublicKeyInfo. Errors if the SPKI algorithm -/// isn't Ed25519 (OID `1.3.101.112`) or the key isn't 32 bytes. -pub fn ed25519_pubkey_from_cert(der: &[u8]) -> Result<[u8; 32], String> { - let (_, cert) = - x509_parser::parse_x509_certificate(der).map_err(|e| format!("parse cert: {e}"))?; - let spki = cert.public_key(); - let alg_oid = &spki.algorithm.algorithm; - if alg_oid.to_id_string() != "1.3.101.112" { - return Err(format!( - "expected Ed25519 SPKI, got OID {}", - alg_oid.to_id_string() - )); - } - let pk = spki.subject_public_key.data.as_ref(); - pk.try_into() - .map_err(|_| format!("Ed25519 pubkey must be 32 bytes, got {}", pk.len())) -} - -/// Custom rustls `ServerCertVerifier` that pins on the leaf cert's -/// SubjectPublicKeyInfo Ed25519 pubkey rather than walking a CA chain — -/// the pragmatic equivalent of TLS raw-public-key (RFC 7250) without -/// changing the wire protocol. No expiry check, no SAN check, no CA -/// chain: matches the reference verifier exactly, including its -/// intentional narrowness. -#[derive(Debug)] -pub struct PubkeyPinVerifier { - pinned: [u8; 32], - crypto: Arc, -} - -impl PubkeyPinVerifier { - /// Verifies handshake signatures with `macula-pqc`'s provider, the one - /// every connection this crate dials runs on. - pub fn new(pinned_pubkey: [u8; 32]) -> Self { - Self { - pinned: pinned_pubkey, - crypto: macula_pqc::client_builder().crypto_provider().clone(), - } - } -} - -impl rustls::client::danger::ServerCertVerifier for PubkeyPinVerifier { - fn verify_server_cert( - &self, - end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - _server_name: &rustls::pki_types::ServerName<'_>, - _ocsp_response: &[u8], - _now: rustls::pki_types::UnixTime, - ) -> Result { - let presented = ed25519_pubkey_from_cert(end_entity.as_ref()) - .map_err(|e| rustls::Error::General(format!("pubkey extract: {e}")))?; - - if presented == self.pinned { - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } else { - Err(rustls::Error::General(format!( - "pubkey mismatch: pinned={} presented={}", - hex(&self.pinned), - hex(&presented) - ))) - } - } - - fn verify_tls12_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, - ) -> Result { - // Ed25519 leaves are TLS 1.3 only; if a 1.2 path somehow - // triggers this, accept — the cert match above is what anchors - // trust, matching the reference verifier's own reasoning. - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) - } - - fn verify_tls13_signature( - &self, - message: &[u8], - cert: &CertificateDer<'_>, - dss: &rustls::DigitallySignedStruct, - ) -> Result { - // Defer to the crypto provider: this verifies `dss` was really - // produced by the leaf cert's own private key, i.e. that the - // peer we pinned by pubkey is the one actually completing this - // handshake, not just quoting someone else's cert. - rustls::crypto::verify_tls13_signature( - message, - cert, - dss, - &self.crypto.signature_verification_algorithms, - ) - } - - fn supported_verify_schemes(&self) -> Vec { - self.crypto - .signature_verification_algorithms - .supported_schemes() - } -} - -/// Skips all server certificate verification. **Development/diagnostic -/// use only** — establishes transport connectivity with no identity -/// guarantee whatsoever. Never use this to dial a station you intend to -/// trust; use [`PubkeyPinVerifier`] once the station's identity is -/// known, matching the reference's own `verify => none` mode. -#[derive(Debug)] -pub struct SkipServerVerification(Arc); - -impl SkipServerVerification { - /// States the signature schemes of `macula-pqc`'s provider, the one every - /// connection this crate dials runs on. - pub fn new() -> Self { - Self(macula_pqc::client_builder().crypto_provider().clone()) - } -} - -impl Default for SkipServerVerification { - fn default() -> Self { - Self::new() - } -} - -impl rustls::client::danger::ServerCertVerifier for SkipServerVerification { - fn verify_server_cert( - &self, - _end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - _server_name: &rustls::pki_types::ServerName<'_>, - _ocsp_response: &[u8], - _now: rustls::pki_types::UnixTime, - ) -> Result { - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, - ) -> Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) - } - - fn verify_tls13_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, - ) -> Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) - } - - fn supported_verify_schemes(&self) -> Vec { - self.0.signature_verification_algorithms.supported_schemes() - } -} - -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use rustls::client::danger::ServerCertVerifier; - - #[test] - fn ed25519_pubkey_extraction_rejects_garbage_der() { - assert!(ed25519_pubkey_from_cert(b"not a certificate").is_err()); - } - - #[test] - fn pinned_verifier_stores_the_exact_bytes_given() { - let pin = [0x42u8; 32]; - let verifier = PubkeyPinVerifier::new(pin); - assert_eq!(verifier.pinned, pin); - } - - /// A synthetic self-signed Ed25519 cert, shaped like what a station - /// running pubkey-pinned trust actually presents — generated with - /// `rcgen` purely for this test (not a runtime dependency, see the - /// module doc). No live station reachable from this crate's test - /// suite happens to be configured this way (the reachable demo - /// fleet all uses `Trust::WebPki` — see `tests/live_station.rs`), so - /// this is the only way to exercise `PubkeyPinVerifier`'s actual - /// matching logic end-to-end without one. - fn synthetic_ed25519_cert() -> (rustls::pki_types::CertificateDer<'static>, [u8; 32]) { - let key_pair = rcgen::KeyPair::generate_for(&rcgen::PKCS_ED25519).expect("keygen"); - let params = rcgen::CertificateParams::new(Vec::::new()).expect("params"); - let cert = params.self_signed(&key_pair).expect("self-sign"); - let der = rustls::pki_types::CertificateDer::from(cert.der().to_vec()); - let pubkey = - ed25519_pubkey_from_cert(der.as_ref()).expect("our own synthetic cert must parse"); - (der, pubkey) - } - - fn fake_server_name() -> rustls::pki_types::ServerName<'static> { - rustls::pki_types::ServerName::try_from("station.example").expect("valid server name") - } - - #[test] - fn extracts_the_real_pubkey_from_a_synthetic_cert() { - // Not asserting a specific value — proves the extraction path - // works end-to-end against a real (if synthetic) cert, distinct - // from `ed25519_pubkey_extraction_rejects_garbage_der`'s - // negative case. - let (_der, pubkey) = synthetic_ed25519_cert(); - assert_ne!( - pubkey, [0u8; 32], - "a real generated key should not be all-zero" - ); - } - - #[test] - fn verify_server_cert_accepts_the_pinned_key() { - let (der, pubkey) = synthetic_ed25519_cert(); - let verifier = PubkeyPinVerifier::new(pubkey); - let result = verifier.verify_server_cert( - &der, - &[], - &fake_server_name(), - &[], - rustls::pki_types::UnixTime::now(), - ); - assert!( - result.is_ok(), - "pinning the cert's real key must succeed: {result:?}" - ); - } - - #[test] - fn verify_server_cert_rejects_a_mismatched_key() { - let (der, pubkey) = synthetic_ed25519_cert(); - let mut wrong = pubkey; - wrong[0] ^= 0xFF; - let verifier = PubkeyPinVerifier::new(wrong); - let result = verifier.verify_server_cert( - &der, - &[], - &fake_server_name(), - &[], - rustls::pki_types::UnixTime::now(), - ); - assert!(result.is_err(), "pinning the WRONG key must fail closed"); - } - - #[test] - fn skip_verification_accepts_anything() { - let (der, _pubkey) = synthetic_ed25519_cert(); - let verifier = SkipServerVerification::new(); - let result = verifier.verify_server_cert( - &der, - &[], - &fake_server_name(), - &[], - rustls::pki_types::UnixTime::now(), - ); - assert!( - result.is_ok(), - "insecure mode must accept any cert, by design" - ); - } -} diff --git a/src/cert_chain.rs b/src/cert_chain.rs deleted file mode 100644 index cb9097f..0000000 --- a/src/cert_chain.rs +++ /dev/null @@ -1,479 +0,0 @@ -//! Direct-dial dual-trust (Slice 7c Direction B) — X.509 cert chain. -//! -//! Managed realms root trust in the realm CA, not in the (keyless) realm -//! tag. A provider embeds its own service-cert chain (leaf ++ org CA, PEM) -//! in its `procedure_advertisement`; a verifying consumer chains it to the -//! realm CA it received at its own issuance. No publisher records, no live -//! authority — the trust material already travels with the advertisement. -//! -//! Ported from `macula_record.erl`'s `verify_advertisement_cert_chain/3` -//! (and its `cert_chain_step_*`/`pem_cert_ders`/`cert_subject_pubkey`/ -//! `validate_path`/`cert_org` helpers, in `src/record/macula_record.erl`) -//! — same algorithm, using `rustls-webpki`'s native path validation instead -//! of hand-rolling `pkix_path_validation`, and `x509-parser` (already used -//! by [`crate::cert`] for the unrelated TLS pubkey-pinning tier) for field -//! extraction. Cross-checked against `macula-go`'s own port -//! (`dht/cert_chain.go`), which uses `crypto/x509`'s native path validation -//! the same way. Opt-in: this has no effect on plain (non-cert-chain) -//! direct-dial, which remains exactly as it was. -//! -//! **Not the same trust tier as [`crate::cert`].** That module verifies a -//! TLS pubkey pin for dialing a KNOWN station — no CA chain, no org -//! semantics, the pubkey IS the identity. This module verifies a resolved -//! advertisement's embedded X.509 chain proves its signer belongs to a -//! specific org, authorized by a realm CA the caller already trusts — a -//! higher, separate, opt-in tier that only matters once direct-dial itself -//! exists. - -use rustls::pki_types::{CertificateDer, UnixTime}; - -use crate::dht::{self, Record}; - -/// Mirrors `macula_record.erl`'s six `cert_chain_step_*` failure atoms -/// (`advertisement_bad_signature`, `no_cert_chain`, `cert_chain_undecodable`, -/// `cert_key_mismatch`, `cert_chain_untrusted`/`{bad_cert, _}`, -/// `cert_org_mismatch`) as distinguishable variants (test with -/// `matches!`/`==`) — never silently treat an unauthorized advertisement as -/// trusted. -#[derive(Debug, PartialEq, Eq)] -pub enum CertChainError { - /// The advertisement's own Ed25519 envelope signature does not verify — - /// checked BEFORE the cert chain is even examined, since nothing in an - /// unverified record can be trusted. Also covers the (practically - /// unreachable once the envelope verifies) case of a structurally - /// malformed `procedure_advertisement` payload — `macula_record.erl` - /// itself has no distinct atom for that case either, since it can't - /// occur without the signer having signed garbage in the first place. - BadSignature, - /// `cert_chain` is absent — the common, unmanaged-realm case. Not - /// itself a sign of tampering; callers that require managed-realm - /// authorization should treat this as "not authorized," not as - /// evidence of an attack. - Absent, - /// `cert_chain` is present but not a decodable PEM bundle containing at - /// least one certificate. - Undecodable, - /// The leaf certificate's Ed25519 subject public key does not match the - /// advertisement's own signing key — the chain does not actually belong - /// to whoever signed this record. - KeyMismatch, - /// The chain does not validate to the given realm CA (expired, wrong - /// issuer, broken path, etc.). - Untrusted, - /// The chain validates, but the leaf certificate's Organization (O) - /// does not match the procedure's expected org segment — a - /// validly-signed cert for the WRONG org, i.e. a squat. - OrgMismatch, -} - -impl std::fmt::Display for CertChainError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let msg = match self { - CertChainError::BadSignature => "advertisement signature does not verify", - CertChainError::Absent => "advertisement carries no cert_chain", - CertChainError::Undecodable => "cert_chain is not a decodable PEM certificate bundle", - CertChainError::KeyMismatch => { - "leaf cert public key does not match the advertisement's signer" - } - CertChainError::Untrusted => "cert chain does not validate to the trusted realm CA", - CertChainError::OrgMismatch => "leaf cert organization does not match the expected org", - }; - write!(f, "cert_chain: {msg}") - } -} - -impl std::error::Error for CertChainError {} - -/// Verifies a resolved `procedure_advertisement` record's embedded X.509 -/// service-cert chain against a trusted realm CA, for Slice 7c Direction B -/// managed-realm authorization. -/// -/// `realm_ca_pem` is the realm CA the caller already trusts (obtained at -/// its own issuance, out of band — never resolved from the mesh itself). -/// `rec` is a resolved `procedure_advertisement`. `expected_org` is the -/// `` segment of the procedure URI the caller intended to reach. -/// -/// Passes (returns `Ok(())`) only when: `rec`'s own envelope signature -/// verifies; `rec` carries a `cert_chain`; the chain decodes to at least -/// one certificate; the leaf certificate's Ed25519 subject public key -/// equals `rec`'s signing key (`rec.key`); the leaf chains to -/// `realm_ca_pem`; and the leaf's Organization RDN equals `expected_org`. -pub fn verify_advertisement_cert_chain( - realm_ca_pem: &[u8], - rec: &Record, - expected_org: &str, -) -> Result<(), CertChainError> { - dht::verify(rec).map_err(|_| CertChainError::BadSignature)?; - let adv = dht::read_procedure_advertisement(rec).map_err(|_| CertChainError::BadSignature)?; - let Some(chain_pem) = adv.cert_chain else { - return Err(CertChainError::Absent); - }; - - let chain_der = decode_cert_chain(&chain_pem)?; - let leaf_der = &chain_der[0]; - - let leaf_key = - crate::cert::ed25519_pubkey_from_cert(leaf_der).map_err(|_| CertChainError::KeyMismatch)?; - if leaf_key != rec.key { - return Err(CertChainError::KeyMismatch); - } - - validate_cert_path(realm_ca_pem, &chain_der)?; - - let leaf_org = leaf_organization(leaf_der).ok_or(CertChainError::OrgMismatch)?; - if leaf_org != expected_org { - return Err(CertChainError::OrgMismatch); - } - Ok(()) -} - -/// Decodes a leaf-first PEM bundle (as embedded: leaf ++ org CA ++ ...) -/// into DER certificates, leaf-first, matching `macula_record`'s -/// `pem_cert_ders/1`. -fn decode_cert_chain(cert_chain_pem: &[u8]) -> Result>, CertChainError> { - let ders: Vec> = x509_parser::pem::Pem::iter_from_buffer(cert_chain_pem) - .filter_map(Result::ok) - .filter(|pem| pem.label == "CERTIFICATE") - .map(|pem| pem.contents) - .collect(); - if ders.is_empty() { - return Err(CertChainError::Undecodable); - } - Ok(ders) -} - -/// The Organization (O) RDN of a leaf cert's Subject, or `None` if absent -/// or unreadable as a string. -fn leaf_organization(der: &[u8]) -> Option { - let (_, cert) = x509_parser::parse_x509_certificate(der).ok()?; - let org = cert - .subject() - .iter_organization() - .next() - .and_then(|attr| attr.as_str().ok()) - .map(str::to_owned); - org -} - -/// Validates `chain` (leaf-first: `[leaf, org_ca, ...]`) to `realm_ca_pem` -/// as trust anchor, with no hostname/SAN check (unlike `crate::cert`'s -/// `ServerCertVerifier` machinery) — matches `macula_record`'s -/// `validate_path/2`, which hands Erlang's `pkix_path_validation` the same -/// leaf..anchor chain and no name constraint of its own either. -fn validate_cert_path(realm_ca_pem: &[u8], chain: &[Vec]) -> Result<(), CertChainError> { - let anchor_ders = decode_cert_chain(realm_ca_pem).map_err(|_| CertChainError::Untrusted)?; - let anchor_der = CertificateDer::from(anchor_ders[0].clone()); - let anchor = - webpki::anchor_from_trusted_cert(&anchor_der).map_err(|_| CertChainError::Untrusted)?; - - let leaf_der = CertificateDer::from(chain[0].clone()); - let end_entity = - webpki::EndEntityCert::try_from(&leaf_der).map_err(|_| CertChainError::Untrusted)?; - let intermediates: Vec = chain[1..] - .iter() - .map(|der| CertificateDer::from(der.clone())) - .collect(); - - // KeyUsage::server_auth() is `required_if_present` — it does not - // require the leaf to carry an EKU extension at all (macula's - // self-issued service certs typically don't), it only rejects a leaf - // that declares an EKU set excluding server_auth. - end_entity - .verify_for_usage( - &[webpki::ring::ED25519], - std::slice::from_ref(&anchor), - &intermediates, - UnixTime::now(), - webpki::KeyUsage::server_auth(), - None, - None, - ) - .map_err(|_| CertChainError::Untrusted)?; - Ok(()) -} - -/// Test certificates for cert-chain authorization: a realm CA, a leaf it -/// issues for an advertiser key and org, and a leaf-first PEM bundle. -/// Shared by this module's tests and `direct_dial`'s. -#[cfg(test)] -pub(crate) mod fixtures { - use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair as RcgenKeyPair}; - - pub(crate) fn test_ca() -> (Vec, rcgen::Issuer<'static, RcgenKeyPair>) { - let key_pair = RcgenKeyPair::generate_for(&rcgen::PKCS_ED25519).expect("ca keygen"); - let mut params = CertificateParams::new(Vec::::new()).expect("ca params"); - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "Test Realm CA"); - dn.push(DnType::OrganizationName, "Test Realm CA"); - params.distinguished_name = dn; - params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); - params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1); - params.not_after = time::OffsetDateTime::now_utc() + time::Duration::hours(24); - let cert = params.self_signed(&key_pair).expect("ca self-sign"); - let pem = cert.pem().into_bytes(); - (pem, rcgen::Issuer::new(params, key_pair)) - } - - pub(crate) fn test_leaf( - ca_issuer: &rcgen::Issuer<'static, RcgenKeyPair>, - advertiser_pub: [u8; 32], - org: &str, - not_after: time::OffsetDateTime, - ) -> Vec { - let subject_spki = rcgen::SubjectPublicKeyInfo::from_der(&ed25519_spki_der(advertiser_pub)) - .expect("advertiser SPKI"); - let mut params = CertificateParams::new(Vec::::new()).expect("leaf params"); - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "test-service"); - dn.push(DnType::OrganizationName, org); - params.distinguished_name = dn; - params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1); - params.not_after = not_after; - let cert = params - .signed_by(&subject_spki, ca_issuer) - .expect("leaf signed_by"); - cert.der().to_vec() - } - - /// DER-encodes a raw 32-byte Ed25519 public key as a SubjectPublicKeyInfo - /// structure (RFC 8410): `SEQUENCE { SEQUENCE { OID 1.3.101.112 }, - /// BIT STRING key }`. rcgen needs this to build a leaf cert whose - /// subject key is a SPECIFIC pre-existing key (the advertiser's own - /// node identity), not a freshly rcgen-generated one. - fn ed25519_spki_der(pubkey: [u8; 32]) -> Vec { - let mut der = vec![ - 0x30, 0x2a, // SEQUENCE, 42 bytes - 0x30, 0x05, // SEQUENCE, 5 bytes (AlgorithmIdentifier) - 0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112 (Ed25519) - 0x03, 0x21, 0x00, // BIT STRING, 33 bytes, 0 unused bits - ]; - der.extend_from_slice(&pubkey); - der - } - - pub(crate) fn pem_bundle(ders: &[Vec]) -> Vec { - let mut out = Vec::new(); - for der in ders { - let b64 = base64_std_encode(der); - out.extend_from_slice(b"-----BEGIN CERTIFICATE-----\n"); - for chunk in b64.as_bytes().chunks(64) { - out.extend_from_slice(chunk); - out.push(b'\n'); - } - out.extend_from_slice(b"-----END CERTIFICATE-----\n"); - } - out - } - - fn base64_std_encode(data: &[u8]) -> String { - use base64::Engine; - base64::engine::general_purpose::STANDARD.encode(data) - } -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use super::fixtures::{pem_bundle, test_ca, test_leaf}; - use super::*; - use crate::dht; - use crate::identity::KeyPair; - - fn advertiser_and_station() -> (KeyPair, KeyPair) { - (KeyPair::generate(), KeyPair::generate()) - } - - #[test] - fn valid_chain_verifies_and_authorizes() { - let (ca_pem, ca_issuer) = test_ca(); - let (advertiser, station) = advertiser_and_station(); - let leaf_der = test_leaf( - &ca_issuer, - advertiser.node_id(), - "acme-corp", - time::OffsetDateTime::now_utc() + time::Duration::hours(1), - ); - - let rec = dht::new_procedure_advertisement_with_cert_chain( - advertiser.node_id(), - "0000/acme-corp/widget.build_v1", - station.node_id(), - Duration::from_secs(3600), - pem_bundle(&[leaf_der]), - ); - let rec = dht::sign(rec, &advertiser); - - assert_eq!( - verify_advertisement_cert_chain(&ca_pem, &rec, "acme-corp"), - Ok(()) - ); - } - - #[test] - fn absent_chain_is_reported_distinctly() { - let (advertiser, station) = advertiser_and_station(); - let rec = dht::new_procedure_advertisement( - advertiser.node_id(), - "0000/acme-corp/widget.build_v1", - station.node_id(), - Duration::from_secs(3600), - ); - let rec = dht::sign(rec, &advertiser); - let (ca_pem, _) = test_ca(); - - assert_eq!( - verify_advertisement_cert_chain(&ca_pem, &rec, "acme-corp"), - Err(CertChainError::Absent) - ); - } - - #[test] - fn bad_envelope_signature_is_checked_before_the_chain() { - let (ca_pem, ca_issuer) = test_ca(); - let (advertiser, station) = advertiser_and_station(); - let leaf_der = test_leaf( - &ca_issuer, - advertiser.node_id(), - "acme-corp", - time::OffsetDateTime::now_utc() + time::Duration::hours(1), - ); - let rec = dht::new_procedure_advertisement_with_cert_chain( - advertiser.node_id(), - "0000/acme-corp/widget.build_v1", - station.node_id(), - Duration::from_secs(3600), - pem_bundle(&[leaf_der]), - ); - let mut rec = dht::sign(rec, &advertiser); - rec.signature[0] ^= 0xFF; - - assert_eq!( - verify_advertisement_cert_chain(&ca_pem, &rec, "acme-corp"), - Err(CertChainError::BadSignature) - ); - } - - #[test] - fn leaf_key_not_matching_the_signer_is_rejected() { - let (ca_pem, ca_issuer) = test_ca(); - let (advertiser, station) = advertiser_and_station(); - let other = KeyPair::generate(); - // Leaf binds `other`'s key, but the advertisement is signed by - // `advertiser` -- the chain does not belong to this record's signer. - let leaf_der = test_leaf( - &ca_issuer, - other.node_id(), - "acme-corp", - time::OffsetDateTime::now_utc() + time::Duration::hours(1), - ); - let rec = dht::new_procedure_advertisement_with_cert_chain( - advertiser.node_id(), - "0000/acme-corp/widget.build_v1", - station.node_id(), - Duration::from_secs(3600), - pem_bundle(&[leaf_der]), - ); - let rec = dht::sign(rec, &advertiser); - - assert_eq!( - verify_advertisement_cert_chain(&ca_pem, &rec, "acme-corp"), - Err(CertChainError::KeyMismatch) - ); - } - - #[test] - fn wrong_org_is_rejected_after_a_valid_chain() { - let (ca_pem, ca_issuer) = test_ca(); - let (advertiser, station) = advertiser_and_station(); - let leaf_der = test_leaf( - &ca_issuer, - advertiser.node_id(), - "acme-corp", - time::OffsetDateTime::now_utc() + time::Duration::hours(1), - ); - let rec = dht::new_procedure_advertisement_with_cert_chain( - advertiser.node_id(), - "0000/other-org/widget.build_v1", - station.node_id(), - Duration::from_secs(3600), - pem_bundle(&[leaf_der]), - ); - let rec = dht::sign(rec, &advertiser); - - assert_eq!( - verify_advertisement_cert_chain(&ca_pem, &rec, "other-org"), - Err(CertChainError::OrgMismatch) - ); - } - - #[test] - fn expired_leaf_is_untrusted() { - let (ca_pem, ca_issuer) = test_ca(); - let (advertiser, station) = advertiser_and_station(); - let leaf_der = test_leaf( - &ca_issuer, - advertiser.node_id(), - "acme-corp", - time::OffsetDateTime::now_utc() - time::Duration::hours(1), - ); - let rec = dht::new_procedure_advertisement_with_cert_chain( - advertiser.node_id(), - "0000/acme-corp/widget.build_v1", - station.node_id(), - Duration::from_secs(3600), - pem_bundle(&[leaf_der]), - ); - let rec = dht::sign(rec, &advertiser); - - assert_eq!( - verify_advertisement_cert_chain(&ca_pem, &rec, "acme-corp"), - Err(CertChainError::Untrusted) - ); - } - - #[test] - fn chain_signed_by_a_different_ca_is_untrusted() { - let (_, ca_issuer) = test_ca(); - let (other_ca_pem, _) = test_ca(); - let (advertiser, station) = advertiser_and_station(); - let leaf_der = test_leaf( - &ca_issuer, - advertiser.node_id(), - "acme-corp", - time::OffsetDateTime::now_utc() + time::Duration::hours(1), - ); - let rec = dht::new_procedure_advertisement_with_cert_chain( - advertiser.node_id(), - "0000/acme-corp/widget.build_v1", - station.node_id(), - Duration::from_secs(3600), - pem_bundle(&[leaf_der]), - ); - let rec = dht::sign(rec, &advertiser); - - assert_eq!( - verify_advertisement_cert_chain(&other_ca_pem, &rec, "acme-corp"), - Err(CertChainError::Untrusted) - ); - } - - #[test] - fn undecodable_chain_is_reported_distinctly() { - let (ca_pem, _) = test_ca(); - let (advertiser, station) = advertiser_and_station(); - let rec = dht::new_procedure_advertisement_with_cert_chain( - advertiser.node_id(), - "0000/acme-corp/widget.build_v1", - station.node_id(), - Duration::from_secs(3600), - b"not a pem cert bundle".to_vec(), - ); - let rec = dht::sign(rec, &advertiser); - - assert_eq!( - verify_advertisement_cert_chain(&ca_pem, &rec, "acme-corp"), - Err(CertChainError::Undecodable) - ); - } -} diff --git a/src/connection.rs b/src/connection.rs deleted file mode 100644 index 3ffd3e4..0000000 --- a/src/connection.rs +++ /dev/null @@ -1,1666 +0,0 @@ -//! The CONNECT/HELLO handshake and the application-frame stream -//! abstraction, ported from `src/peering/macula_peering_conn.erl` -//! (`macula-io/macula`) — see `plans/PLAN_WIRE_PROTOCOL.md` §3. -//! -//! Only the client role's `connecting -> handshaking -> connected` path -//! is implemented. [`Session`] is the handshaked connection: one reader -//! routes every frame on its control stream, so calls, subscriptions and -//! serving run on it at the same time. [`FrameStream`] is the "send/receive -//! signed application frames on one QUIC stream" primitive that -//! [`Session::open_dedicated_stream`] hands out for content transfer (§12) -//! and streaming RPC (§13), which both run on dedicated streams rather than -//! the control stream. - -use std::collections::HashMap; -use std::sync::{Arc, Weak}; -use std::time::Duration; - -use crate::bolt4; -use crate::cbor::Value; -use crate::control_channel::{self, Channel}; -use crate::frame::{self, Decoded, HelloInfo}; -use crate::identity::KeyPair; -use crate::transport::{self, ConnectError, Trust}; - -pub use crate::control_channel::{ - CallError, RecvEventError, SendError, SessionEndReason, Subscription, -}; - -/// A boxed, `'static` future — hand-rolled rather than pulling in the -/// `futures` crate for one type alias. -pub type BoxFuture<'a, T> = std::pin::Pin + Send + 'a>>; - -/// Answers one inbound CALL. `Ok(payload)` sends a RESULT; `Err(reason)` -/// sends an ERROR (BOLT#4 `unknown_error`, `detail = reason`); a panic -/// inside the handler (caught via [`tokio::spawn`], the same "one -/// transient task per call" shape `macula_station_link.erl` uses one -/// process per call for) is sent as ERROR `temporary_relay_failure` — -/// matching that module's own `safe_invoke_handler/4` mapping exactly -/// (including sending no `detail` on a crash, since the reference -/// doesn't either — it only logs locally). -/// -/// A map payload arrives with the caller's 32-byte node id under -/// `"caller"`: the caller the CALL's signature was verified against, -/// replacing any `"caller"` the sender put in the payload. A payload that -/// isn't a map arrives unchanged and carries no caller. -pub type CallHandler = - Arc BoxFuture<'static, Result> + Send + Sync>; - -/// Matches `HANDSHAKE_TIMEOUT_MS` in `macula_peering_conn.erl`: CONNECT -/// -> HELLO is sub-second on a healthy peer; this is generous. The most -/// common real-world trigger for hitting it is a protocol version -/// mismatch — bytes accumulate but never form a valid frame, so the -/// station-side symptom and this crate's symptom are the same shape. -pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30); - -/// Default timeout for a single CALL awaiting its RESULT/ERROR. Not from -/// the reference source (macula's own CALL timeout is caller-supplied -/// per-call via `deadline_ms` inside the frame itself, not a transport- -/// level default) — a reasonable local default for this crate's API. -pub const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(30); - -/// Bound on a single read from a QUIC stream while accumulating a frame. -/// Not a protocol limit — just how much to ask the stream for at once; -/// `frame::decode`'s own `MAX_FRAME_BYTES` is the real cap. -const READ_CHUNK: usize = 64 * 1024; - -// --------------------------------------------------------------------- -// FrameStream — send/receive signed application frames on one dedicated -// QUIC stream (content transfer, streaming RPC). -// --------------------------------------------------------------------- - -pub struct FrameStream { - send: quinn::SendStream, - recv: quinn::RecvStream, - /// Bytes read but not yet consumed by a decoded frame — carried - /// over between reads so nothing is ever dropped. - buf: Vec, -} - -#[derive(Debug)] -pub enum SendFrameError { - Encode(frame::EncodeFrameError), - Write(quinn::WriteError), -} - -impl std::fmt::Display for SendFrameError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SendFrameError::Encode(e) => write!(f, "encoding frame: {e}"), - SendFrameError::Write(e) => write!(f, "writing to stream: {e}"), - } - } -} - -impl std::error::Error for SendFrameError {} - -#[derive(Debug)] -pub enum RecvFrameError { - Read(quinn::ReadError), - StreamClosed, - Decode(frame::DecodeFrameError), - Timeout, -} - -impl std::fmt::Display for RecvFrameError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - RecvFrameError::Read(e) => write!(f, "reading from stream: {e}"), - RecvFrameError::StreamClosed => write!(f, "peer closed the stream"), - RecvFrameError::Decode(e) => write!(f, "decoding a frame: {e}"), - RecvFrameError::Timeout => write!(f, "timed out waiting for a frame"), - } - } -} - -impl std::error::Error for RecvFrameError {} - -/// Why a CALL on a dedicated stream got no reply. -#[derive(Debug)] -pub enum StreamCallError { - Send(SendFrameError), - Recv(RecvFrameError), -} - -impl std::fmt::Display for StreamCallError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - StreamCallError::Send(e) => write!(f, "sending CALL: {e}"), - StreamCallError::Recv(e) => write!(f, "awaiting RESULT/ERROR: {e}"), - } - } -} - -impl std::error::Error for StreamCallError {} - -impl FrameStream { - pub(crate) fn new(send: quinn::SendStream, recv: quinn::RecvStream) -> Self { - Self { - send, - recv, - buf: Vec::new(), - } - } - - /// Any bytes already read past the last decoded frame. - pub fn leftover_bytes(&self) -> &[u8] { - &self.buf - } - - /// Aborts both halves with `code`, writing nothing: RESET_STREAM on the - /// send half and STOP_SENDING on the receive half. Stopping has to be - /// explicit, since a receive half dropped unread sends STOP_SENDING with - /// code 0. - pub(crate) fn abort_both(mut self, code: u32) { - let code = quinn::VarInt::from_u32(code); - let _ = self.send.reset(code); - let _ = self.recv.stop(code); - } - - /// Finishes the send half, so what was written still reaches the peer, - /// and stops the receive half with `code`. - pub(crate) fn finish_and_stop_reading(mut self, code: u32) { - let _ = self.send.finish(); - let _ = self.recv.stop(quinn::VarInt::from_u32(code)); - } - - pub async fn send_frame(&mut self, frame: Value) -> Result<(), SendFrameError> { - let encoded = frame::encode(&frame).map_err(SendFrameError::Encode)?; - self.send - .write_all(&encoded) - .await - .map_err(SendFrameError::Write) - } - - /// Read the next complete application frame, using (and updating) - /// any bytes already buffered. - pub async fn recv_frame(&mut self) -> Result { - let mut chunk = vec![0u8; READ_CHUNK]; - loop { - match frame::decode(&self.buf) { - Ok(Decoded::Frame(value, consumed)) => { - self.buf.drain(..consumed); - return Ok(value); - } - Ok(Decoded::More(_)) => {} - Err(e) => return Err(RecvFrameError::Decode(e)), - } - let n = self - .recv - .read(&mut chunk) - .await - .map_err(RecvFrameError::Read)? - .ok_or(RecvFrameError::StreamClosed)?; - self.buf.extend_from_slice(&chunk[..n]); - } - } - - /// As [`recv_frame`](Self::recv_frame), bounded by `timeout`. - pub async fn recv_frame_timeout(&mut self, timeout: Duration) -> Result { - tokio::time::timeout(timeout, self.recv_frame()) - .await - .unwrap_or(Err(RecvFrameError::Timeout)) - } - - /// Send a signed CALL for `procedure` and wait for the matching - /// RESULT or ERROR, correlated by `call_id`. A dedicated stream carries - /// only its own exchange, so a frame with another `call_id` is skipped. - pub async fn call( - &mut self, - procedure: &str, - realm: [u8; 32], - payload: Value, - deadline_ms: i128, - identity: &KeyPair, - timeout: Duration, - ) -> Result { - let call_id: [u8; 16] = rand::random(); - let spec = frame::CallSpec::new( - call_id, - procedure, - realm, - payload, - deadline_ms, - identity.node_id(), - ); - let signed = frame::sign(frame::call(&spec), identity); - self.send_frame(signed) - .await - .map_err(StreamCallError::Send)?; - - tokio::time::timeout(timeout, self.await_call_response(call_id)) - .await - .unwrap_or(Err(StreamCallError::Recv(RecvFrameError::Timeout))) - } - - /// As [`call`](Self::call), additionally attaching `ucan_token` to the - /// outgoing CALL frame — for invoking a procedure gated by a - /// [`crate::ucan::Policy::required`] policy. A procedure that isn't - /// gated ignores the token; one that is checks it (see - /// [`Session::serve_one_call_gated`]) before ever running its - /// handler, so an invalid/missing token comes back as a BOLT#4 - /// `unauthorized` error frame, not a Rust error from this call. - /// - /// One parameter over [`call`](Self::call)'s own count, for the one - /// new thing this adds — same reasoning - /// [`crate::direct_dial::keep_advertised_direct`] already gives for - /// its own allow. - #[allow(clippy::too_many_arguments)] - pub async fn call_with_ucan( - &mut self, - procedure: &str, - realm: [u8; 32], - payload: Value, - deadline_ms: i128, - identity: &KeyPair, - timeout: Duration, - ucan_token: Vec, - ) -> Result { - let call_id: [u8; 16] = rand::random(); - let mut spec = frame::CallSpec::new( - call_id, - procedure, - realm, - payload, - deadline_ms, - identity.node_id(), - ); - spec.ucan_token = ucan_token; - let signed = frame::sign(frame::call(&spec), identity); - self.send_frame(signed) - .await - .map_err(StreamCallError::Send)?; - - tokio::time::timeout(timeout, self.await_call_response(call_id)) - .await - .unwrap_or(Err(StreamCallError::Recv(RecvFrameError::Timeout))) - } - - async fn await_call_response( - &mut self, - call_id: [u8; 16], - ) -> Result { - loop { - let value = self.recv_frame().await.map_err(StreamCallError::Recv)?; - if frame::frame_call_id(&value) != Some(call_id) { - continue; - } - if let Ok(response) = frame::parse_call_response(&value) { - return Ok(response); - } - // Matching call_id but not a result/error shape: keep - // waiting rather than erroring, since nothing else in the - // protocol is expected to carry this call's id. - } - } -} - -// --------------------------------------------------------------------- -// Session — the handshaked connection. One reader routes every frame on -// its control stream, and writers take turns: see control_channel.rs. -// --------------------------------------------------------------------- - -/// A completed, handshaked connection to a macula-station, and a handle to -/// it: clones share the one connection. Calls, subscriptions and serving -/// run on it at the same time, because one reader routes every frame on its -/// control stream to whatever waits for it: a RESULT or ERROR to its call, -/// an EVENT to each matching [`Subscription`], and an inbound CALL signed by -/// its caller to a queue of 64. GOODBYE, HELLO or CONNECT after the -/// handshake, a frame that can't be decoded, or a write that stalls past -/// the 30 second send timeout ends the session; every later operation then -/// reports [`SessionEndReason`], the connection closes, and the end is -/// logged once through the `log` facade. Other frames nothing waits for are -/// counted ([`unrouted_frame_counts`](Self::unrouted_frame_counts)). -/// -/// **Always call [`close`](Self::close) before the last handle goes out of -/// scope, not just after your own logic is done with it -- especially right -/// after a send-then-return call like [`publish`](Self::publish) or -/// [`serve_one_call`](Self::serve_one_call).** Dropping the last handle -/// ends the session and tears the connection down at once (flushing -/// outstanding QUIC stream data needs `.await`, which `Drop` can't do), with -/// no guarantee the last write actually reached the peer -- see -/// [`close`](Self::close)'s own doc for the mechanism, and -/// [`serve_one_call`](Self::serve_one_call)'s for the specific, -/// confirmed-live way this bites a spawned provider task. -#[derive(Clone)] -pub struct Session { - inner: Arc, - pub station: HelloInfo, -} - -/// What every handle of one session shares. -pub(crate) struct SessionInner { - /// Direct dial opens dedicated streams on it when it reuses this session. - connection: Arc, - channel: Arc, - hello: HelloInfo, - /// The requests using this session, when direct dial dialed it for them. - leases: Option, -} - -impl Drop for SessionInner { - fn drop(&mut self) { - // The last handle went without close: end the session, which stops - // its reader and writers, and close the connection at once. - self.channel.end(SessionEndReason::Closed, true); - self.connection.close(0u32.into(), b"dropped"); - } -} - -impl crate::open_sessions::Live for SessionInner { - fn is_live(&self) -> bool { - self.channel.end_reason().is_none() && self.connection.close_reason().is_none() - } -} - -impl crate::open_sessions::Leased for Session { - fn leases(&self) -> Option<&crate::open_sessions::Leases> { - self.inner.leases.as_ref() - } -} - -#[derive(Debug)] -pub enum HandshakeError { - Transport(ConnectError), - OpenStream(quinn::ConnectionError), - Write(quinn::WriteError), - Read(quinn::ReadError), - /// The peer closed the stream before a complete frame arrived. - StreamClosed, - Timeout, - Encode(frame::EncodeFrameError), - Decode(frame::DecodeFrameError), - /// Received a frame, but it wasn't a HELLO — a station is never - /// expected to send anything else at this point in the handshake. - UnexpectedFrameType(frame::ParseHelloError), - /// The HELLO frame's own signature didn't verify against the - /// node_id it claims — proves nothing about who actually sent it. - SignatureInvalid(frame::VerifyError), - /// The station completed the handshake but refused the connection - /// (`accepted = false`), e.g. a puzzle-invalid or unrecognized - /// identity. - Refused { - refusal_code: Option, - }, -} - -impl std::fmt::Display for HandshakeError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - HandshakeError::Transport(e) => write!(f, "transport: {e}"), - HandshakeError::OpenStream(e) => write!(f, "opening control stream: {e}"), - HandshakeError::Write(e) => write!(f, "sending CONNECT: {e}"), - HandshakeError::Read(e) => write!(f, "reading from control stream: {e}"), - HandshakeError::StreamClosed => { - write!(f, "station closed the stream before HELLO arrived") - } - HandshakeError::Timeout => write!( - f, - "no HELLO within {HANDSHAKE_TIMEOUT:?} (likely a protocol mismatch)" - ), - HandshakeError::Encode(e) => write!(f, "encoding CONNECT: {e}"), - HandshakeError::Decode(e) => write!(f, "decoding the station's response: {e}"), - HandshakeError::UnexpectedFrameType(e) => write!(f, "expected a HELLO frame: {e}"), - HandshakeError::SignatureInvalid(e) => write!(f, "HELLO signature check failed: {e}"), - HandshakeError::Refused { refusal_code } => { - write!( - f, - "station refused the connection (refusal_code = {refusal_code:?})" - ) - } - } - } -} - -impl std::error::Error for HandshakeError {} - -/// Dial `host:port` and complete the full CONNECT/HELLO handshake: -/// open a QUIC connection, open the control stream, send a signed -/// CONNECT built from `identity`, and wait for a HELLO whose own -/// signature verifies against the node_id it claims. The session's reader -/// starts right after. -/// -/// `identity` **must** be puzzle-hardened -/// ([`KeyPair::generate_with_puzzle`](crate::identity::KeyPair::generate_with_puzzle)) -/// — see that function's own doc and `plans/PLAN_WIRE_PROTOCOL.md` §5's -/// callout: an unhardened identity fails this handshake silently (the -/// QUIC/TLS layer looks healthy right up until the HELLO never accepts). -pub async fn connect( - host: &str, - port: u16, - trust: Trust, - identity: &KeyPair, -) -> Result { - tokio::time::timeout( - HANDSHAKE_TIMEOUT, - connect_inner(host, port, trust, identity, None), - ) - .await - .unwrap_or(Err(HandshakeError::Timeout)) -} - -/// [`connect`], for a session direct dial dials for its own requests. The -/// session counts the requests using it, starting with the one that dialed -/// it ([`Leases`](crate::open_sessions::Leases)), so a request that finds it -/// open shares it, and it closes when the last one is done. -pub(crate) async fn connect_leased( - host: &str, - port: u16, - trust: Trust, - identity: &KeyPair, -) -> Result { - let leases = Some(crate::open_sessions::Leases::new()); - tokio::time::timeout( - HANDSHAKE_TIMEOUT, - connect_inner(host, port, trust, identity, leases), - ) - .await - .unwrap_or(Err(HandshakeError::Timeout)) -} - -async fn connect_inner( - host: &str, - port: u16, - trust: Trust, - identity: &KeyPair, - leases: Option, -) -> Result { - let connection = transport::connect(host, port, trust) - .await - .map_err(HandshakeError::Transport)?; - - let (mut send, mut recv) = connection - .open_bi() - .await - .map_err(HandshakeError::OpenStream)?; - - let connect_spec = - crate::frame::ConnectSpec::new(identity.node_id(), identity.puzzle_evidence()); - let connect_frame = frame::sign(frame::connect(&connect_spec), identity); - let encoded = frame::encode(&connect_frame).map_err(HandshakeError::Encode)?; - send.write_all(&encoded) - .await - .map_err(HandshakeError::Write)?; - - let (hello_value, buf) = read_one_frame(&mut recv).await?; - - let station = frame::parse_hello(&hello_value).map_err(HandshakeError::UnexpectedFrameType)?; - frame::verify(&hello_value, &station.node_id).map_err(HandshakeError::SignatureInvalid)?; - - if !station.accepted { - return Err(HandshakeError::Refused { - refusal_code: station.refusal_code, - }); - } - - let connection = Arc::new(connection); - let (identity_node, station_node) = (identity.node_id(), station.node_id); - // The session signs the frames it sends on its own account (replies its - // reader makes, UNSUBSCRIBE on a dropped subscription) with its own copy - // of the identity it connected under. - let own_identity = KeyPair::from_seed_bytes(identity.private_bytes()); - let hello = station.clone(); - let inner = Arc::new_cyclic(|this: &Weak| { - let (this, ended_connection) = (this.clone(), connection.clone()); - let channel = Channel::start( - Box::new(recv), - buf, - Box::new(send), - own_identity, - station_node, - control_channel::SEND_TIMEOUT, - Box::new(move |reason: &SessionEndReason, closed_here: bool| { - // A session whose control stream ended is no longer offered - // for reuse, and its connection closes. - crate::open_sessions::live().unregister_weak(identity_node, station_node, &this); - if !closed_here { - ended_connection.close(0u32.into(), reason.to_string().as_bytes()); - } - }), - ); - SessionInner { - connection, - channel, - hello, - leases, - } - }); - crate::open_sessions::live().register(identity_node, station_node, &inner); - Ok(Session { inner, station }) -} - -/// Read from `recv` until one complete frame has been decoded, returning -/// it along with any leftover bytes already read that belong to the -/// *next* frame, which the session's reader starts from. -async fn read_one_frame(recv: &mut quinn::RecvStream) -> Result<(Value, Vec), HandshakeError> { - let mut buf = Vec::new(); - let mut chunk = vec![0u8; READ_CHUNK]; - loop { - match frame::decode(&buf) { - Ok(Decoded::Frame(value, consumed)) => { - buf.drain(..consumed); - return Ok((value, buf)); - } - Ok(Decoded::More(_)) => {} - Err(e) => return Err(HandshakeError::Decode(e)), - } - let n = recv - .read(&mut chunk) - .await - .map_err(HandshakeError::Read)? - .ok_or(HandshakeError::StreamClosed)?; - buf.extend_from_slice(&chunk[..n]); - } -} - -/// Errors from [`Session::serve_one_call`]. -#[derive(Debug)] -pub enum ServeCallError { - /// No inbound CALL arrived within the requested timeout. - Timeout, - /// The session ended. - SessionEnded(SessionEndReason), - /// Sending the reply failed. - Send(SendError), -} - -impl std::fmt::Display for ServeCallError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ServeCallError::Timeout => write!(f, "timed out waiting for an inbound CALL"), - ServeCallError::SessionEnded(reason) => write!(f, "the session has ended: {reason}"), - ServeCallError::Send(e) => write!(f, "sending the reply: {e}"), - } - } -} - -impl std::error::Error for ServeCallError {} - -/// Errors from [`Session::run_subscriber`]. -#[derive(Debug)] -pub enum RunSubscriberError { - Subscribe(SendError), - Recv(RecvEventError), -} - -impl std::fmt::Display for RunSubscriberError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - RunSubscriberError::Subscribe(e) => write!(f, "subscribing: {e}"), - RunSubscriberError::Recv(e) => write!(f, "{e}"), - } - } -} - -impl std::error::Error for RunSubscriberError {} - -impl Session { - /// A handle to a session found open in this process's open sessions. - pub(crate) fn from_open(inner: Arc) -> Session { - Session { - station: inner.hello.clone(), - inner, - } - } - - /// Whether `other` is a handle to this same session. - pub(crate) fn is_same_session(&self, other: &Session) -> bool { - Arc::ptr_eq(&self.inner, &other.inner) - } - - /// The remote address this session's connection is with. - pub fn remote_address(&self) -> std::net::SocketAddr { - self.inner.connection.remote_address() - } - - /// Why this session ended, once it has. - pub fn end_reason(&self) -> Option { - self.inner.channel.end_reason() - } - - /// Resolves once this session has ended, with why. - pub async fn ended(&self) -> SessionEndReason { - self.inner.channel.ended().await - } - - /// How many frames of each type the station sent that nothing on this - /// session was waiting for, such as the station's own advertise - /// broadcasts. They are dropped, and at most one log line per type per - /// minute reports them, except a dropped CALL, RESULT or ERROR, which gets - /// a drop warning instead (see - /// [`drop_warning_interval`](Self::drop_warning_interval)). - pub fn unrouted_frame_counts(&self) -> HashMap { - self.inner.channel.unrouted_frame_counts() - } - - /// How long this session's drop warning intervals last. The first inbound - /// CALL or reply of an interval this session drops, and the first stream - /// it refuses, is logged at once with the reason; the rest of the same - /// kind in that interval are counted into one closing line when it ends. - /// Defaults to 60 seconds. - pub fn drop_warning_interval(&self) -> Duration { - self.inner.channel.drop_warnings().interval() - } - - /// Sets [`drop_warning_interval`](Self::drop_warning_interval), for the - /// intervals that start after this. - pub fn set_drop_warning_interval(&self, interval: Duration) { - self.inner.channel.drop_warnings().set_interval(interval); - } - - pub(crate) fn drop_warnings(&self) -> &crate::control_channel::drop_warning::DropWarnings { - self.inner.channel.drop_warnings() - } - - /// Open a new dedicated QUIC stream on this same connection, separate - /// from the control stream — the mechanism content transfer (§12) - /// and streaming RPC (§13), both use instead of the control stream. - pub async fn open_dedicated_stream(&self) -> Result { - let (send, recv) = self.inner.connection.open_bi().await?; - Ok(FrameStream::new(send, recv)) - } - - /// Accept the next dedicated stream the *peer* opens toward us — - /// e.g. the station routing an inbound STREAM_OPEN for a procedure - /// this session has [`advertise`](Self::advertise)d (§13.2). Blocks - /// until one arrives. - /// - /// The receiving side has no advance notice of why a new stream - /// arrived; §7 of `plans/PLAN_WIRE_PROTOCOL.md` says to read the - /// stream's own first frame to learn its purpose, which is exactly - /// what a caller of this method does next via the returned - /// `FrameStream`'s own `recv_frame`. The reference (`quicer`-backed - /// Erlang) has a documented race here — the peer's first bytes can - /// arrive before the owning process is notified the stream exists at - /// all, because its NIF stream resources start passive and only - /// begin delivering once explicitly armed *after* the notification. - /// That race doesn't apply here: `quinn`/QUIC buffers inbound stream - /// data at the transport layer regardless of whether or when the - /// application starts reading, so nothing analogous to arm before - /// read is needed on this side. - pub async fn accept_dedicated_stream(&self) -> Result { - let (send, recv) = self.inner.connection.accept_bi().await?; - Ok(FrameStream::new(send, recv)) - } - - /// Send a signed CALL on the control stream and wait for the matching - /// RESULT or ERROR, correlated by `call_id`. Other calls, subscriptions - /// and serving on this session carry on meanwhile. `timeout` covers the - /// whole call, its turn to write included; when it runs out the call - /// returns [`CallError::Timeout`], and when the session ends first - /// [`CallError::SessionEnded`]. Both say whether the CALL may have - /// reached the station. Announces `rpc.sent_v1` once the CALL is written - /// and `rpc.completed_v1` when the call returns, through this session's - /// own writer, so the facts never cost the call time — see - /// `RPC_SENT_TOPIC` for why these are always on. - pub async fn call( - &self, - procedure: &str, - realm: [u8; 32], - payload: Value, - deadline_ms: i128, - identity: &KeyPair, - timeout: Duration, - ) -> Result { - let spec = frame::CallSpec::new( - rand::random(), - procedure, - realm, - payload, - deadline_ms, - identity.node_id(), - ); - self.announced_call(&spec, identity, timeout).await - } - - /// As [`call`](Self::call), attaching `ucan_token` (e.g. from - /// [`crate::ucan::create`]) to the outgoing CALL — for invoking a - /// procedure gated by a [`crate::ucan::Policy::required`] policy on - /// the provider side. A procedure that isn't gated ignores the token; - /// one that is checks it before ever running its handler, so an - /// invalid or missing token comes back as a BOLT#4 `unauthorized` ERROR - /// frame, not a Rust error from this call. Announces - /// `rpc.sent_v1`/`rpc.completed_v1` the same way [`call`](Self::call) - /// does. - #[allow(clippy::too_many_arguments)] - pub async fn call_with_ucan( - &self, - procedure: &str, - realm: [u8; 32], - payload: Value, - deadline_ms: i128, - identity: &KeyPair, - timeout: Duration, - ucan_token: Vec, - ) -> Result { - let mut spec = frame::CallSpec::new( - rand::random(), - procedure, - realm, - payload, - deadline_ms, - identity.node_id(), - ); - spec.ucan_token = ucan_token; - self.announced_call(&spec, identity, timeout).await - } - - async fn announced_call( - &self, - spec: &frame::CallSpec, - identity: &KeyPair, - timeout: Duration, - ) -> Result { - let request_id: [u8; 16] = rand::random(); - let sent = rpc_fact( - RPC_SENT_TOPIC, - spec.realm, - identity, - request_id_payload(request_id), - ); - let result = self - .inner - .channel - .call(spec, identity, timeout, Some(sent)) - .await; - self.inner - .channel - .hand_off(rpc_completed(spec.realm, identity, request_id, &result)); - result - } - - /// A CALL on this session without RPC telemetry facts, for a pool - /// calling on its links, as macula's pool calls through - /// `macula_station_link:call`. - pub(crate) async fn link_call( - &self, - spec: &frame::CallSpec, - identity: &KeyPair, - timeout: Duration, - ) -> Result { - self.inner.channel.call(spec, identity, timeout, None).await - } - - /// Send a signed PUBLISH, carrying the end-to-end `publisher_sig` - /// (over topic/realm/publisher/seq/payload, independent of frame - /// type) so the resulting EVENT survives being relayed beyond one - /// hop — a station verifies an EVENT's per-hop `signature` against - /// whichever station forwarded it, which only matches on hop 1; - /// every hop after that needs `publisher_sig` instead. Matches the - /// Erlang reference SDK's own default (`pubsub_emit_publisher_sig`, - /// true since macula 4.6.0). Fire-and-forget — no reply is expected - /// on the wire; a subscriber (this session included, if subscribed - /// to the same topic/realm) receives an EVENT asynchronously, through - /// its [`Subscription`]. - pub async fn publish( - &self, - spec: &frame::PublishSpec, - identity: &KeyPair, - ) -> Result<(), SendError> { - let unsigned = frame::publish(spec); - let with_publisher_sig = frame::sign_publisher(unsigned, identity); - let signed = frame::sign(with_publisher_sig, identity); - self.inner.channel.send(&signed).await - } - - /// Starts a subscription with its own queue of 256 events. It receives - /// every EVENT whose realm is `spec`'s and whose topic matches `spec`'s - /// topic by the station's rule: both split on "/", equal segment counts, - /// and each segment equal or "*", which matches exactly one whole - /// segment. SUBSCRIBE goes to the station unless another subscription on - /// this session already holds that realm and topic, and closing or - /// dropping the last one sends UNSUBSCRIBE. - pub async fn subscribe( - &self, - spec: &frame::SubscribeSpec, - identity: &KeyPair, - ) -> Result { - self.inner.channel.subscribe(spec, identity).await - } - - /// Send a signed ADVERTISE (§6.9) — registers this connection as the - /// handler for `spec`'s `(realm, procedure)`. Fire-and-forget on the - /// wire; the station then routes inbound CALLs (control stream) and - /// STREAM_OPENs (a fresh dedicated stream — see - /// [`accept_dedicated_stream`](Self::accept_dedicated_stream)) for - /// that procedure back to this connection. - pub async fn advertise( - &self, - spec: &frame::AdvertiseSpec, - identity: &KeyPair, - ) -> Result<(), SendError> { - let signed = frame::sign(frame::advertise(spec), identity); - self.inner.channel.send(&signed).await - } - - /// Send a signed UNADVERTISE. Fire-and-forget. - pub async fn unadvertise( - &self, - spec: &frame::UnadvertiseSpec, - identity: &KeyPair, - ) -> Result<(), SendError> { - let signed = frame::sign(frame::unadvertise(spec), identity); - self.inner.channel.send(&signed).await - } - - /// Sends an ADVERTISE for `spec` immediately, then again every - /// `interval`, until `stop` resolves. [`advertise`](Self::advertise)'s - /// own doc notes the station's registration is tied to the connection - /// that sent it — a long-lived server needs to keep re-asserting it. - /// [`advertise`](Self::advertise) is a stateless, side-effect-free-on- - /// repeat wire send (unlike the Erlang reference's `advertise/5`, which - /// spawns a real per-call OTP supervisor and so needs a `reuse_sup` - /// option to avoid leaking one per tick), so there is nothing - /// equivalent to worry about leaking here — same reasoning - /// `macula-go`'s `KeepAdvertised` already applied and verified - /// live. - /// - /// A failed tick is reported via `on_error` but does not stop the - /// loop — it tries again at the next interval regardless. This cannot - /// repair a dead session on its own; if the session has ended, every - /// tick will keep failing until `stop` resolves. See - /// [`crate::direct_dial::keep_advertised_direct`] for the direct-dial - /// equivalent (same shape, same reasoning). - pub async fn keep_advertised( - &self, - spec: &frame::AdvertiseSpec, - identity: &KeyPair, - interval: Duration, - stop: F, - on_error: impl Fn(SendError), - ) where - F: std::future::Future, - { - tokio::pin!(stop); - let mut ticker = tokio::time::interval(interval); - loop { - tokio::select! { - _ = &mut stop => return, - _ = ticker.tick() => { - if let Err(e) = self.advertise(spec, identity).await { - on_error(e); - } - } - } - } - } - - /// The provider role's counterpart to [`call`](Self::call): wait for - /// the next inbound CALL, bounded by `timeout`, look it up via - /// `lookup`, invoke the matching handler, and send the resulting RESULT - /// or ERROR back over this same connection — see - /// `plans/PLAN_WIRE_PROTOCOL.md` §6.9's routing description and - /// `macula_station_link.erl`'s `handle_inbound_call/2`, which this - /// mirrors field for field, including its BOLT#4 error-code mapping. - /// - /// Inbound CALLs wait in this session's queue of 64 until served, while - /// calls and subscriptions on the same session carry on. A CALL that - /// doesn't fit gets `temporary_relay_failure` at once, and serving - /// carries on with the calls already queued. The reader queues only a - /// CALL whose signature verifies against the `caller` it names. - /// - /// A caller wanting a long-lived server loops on this: - /// - /// ```no_run - /// # use std::time::Duration; - /// # async fn example(session: &macula_rust::connection::Session, identity: &macula_rust::identity::KeyPair, lookup: impl Fn(&[u8; 32], &str) -> Option) { - /// loop { - /// if let Err(e) = session.serve_one_call(&lookup, identity, Duration::from_secs(30)).await { - /// // ServeCallError::Timeout just means nothing arrived -- keep looping. - /// eprintln!("{e}"); - /// } - /// } - /// # } - /// ``` - /// - /// **Do not let the last handle to this `Session` drop right after this - /// call returns -- call [`close`](Self::close) on it explicitly first.** - /// This is the single most common way to lose the RESULT/ERROR you just - /// sent: `serve_one_call` returning `Ok(())` only means the reply was - /// handed to quinn's own send-scheduling machinery, exactly like - /// [`close`](Self::close)'s own doc explains for `write_all`/`finish` - /// -- dropping the last handle does nothing to wait for that to reach - /// the peer before the connection is torn down, while `close` has a - /// deliberate bounded drain for precisely this. Confirmed live - /// 2026-09-05 with the single most natural-looking way to hit it: - /// spawning the only handle into its own `tokio::spawn` task with - /// nothing following the `.await` -- the task can complete and drop it - /// within microseconds of the write, deterministically under a - /// multi-threaded runtime, losing the reply every time. Keep a handle - /// outside the task and close it explicitly instead, same as this - /// crate's own `tests/live_station.rs` does for every spawned provider - /// role. - pub async fn serve_one_call( - &self, - lookup: L, - identity: &KeyPair, - timeout: Duration, - ) -> Result<(), ServeCallError> - where - L: Fn(&[u8; 32], &str) -> Option, - { - self.serve_one_call_gated( - lookup, - |_, _| crate::ucan::Policy::open(), - identity, - timeout, - ) - .await - } - - /// [`serve_one_call`](Self::serve_one_call), additionally gating each - /// inbound CALL through `policy` BEFORE `lookup` runs — mirrors - /// `macula_station_link.erl`'s `handle_inbound_call/2` exactly: an - /// open policy (the default [`serve_one_call`](Self::serve_one_call) - /// uses) behaves identically; a [`crate::ucan::Policy::required`] - /// policy demands a CALL's `ucan_token` verify against the required - /// issuer and name the CALL's `caller` as its audience, and refuses - /// with BOLT#4 `unauthorized` WITHOUT ever invoking `lookup` or a - /// handler if it doesn't — a [`CallHandler`] never sees the raw token - /// either way, matching the reference's own handler contract (payload - /// only). - /// - /// Before any policy runs, the CALL's signature must verify against the - /// `caller` it names; the session's reader drops a CALL that doesn't, - /// with no reply, as `macula_station_link.erl`'s `on_inbound_call/3` - /// does. - pub async fn serve_one_call_gated( - &self, - lookup: L, - policy: P, - identity: &KeyPair, - timeout: Duration, - ) -> Result<(), ServeCallError> - where - L: Fn(&[u8; 32], &str) -> Option, - P: Fn(&[u8; 32], &str) -> crate::ucan::Policy, - { - tokio::time::timeout(timeout, async { - let call = self - .inner - .channel - .next_inbound_call() - .await - .map_err(ServeCallError::SessionEnded)?; - let reply = build_call_reply(call, &lookup, &policy, identity, Some(self)).await; - self.inner - .channel - .send(&frame::sign(reply, identity)) - .await - .map_err(ServeCallError::Send) - }) - .await - .unwrap_or(Err(ServeCallError::Timeout)) - } - - /// Bounds how long [`close`](Self::close) waits after its last write - /// before hard-closing the connection -- see that method's own doc - /// for why this exists at all. Short relative to the Erlang - /// reference's own 5s draining-state upper bound - /// (`macula_peering.erl`, `?DRAIN_TIMEOUT_MS`): this side only needs - /// to cover quinn's own internal send-scheduling latency, not a full - /// round trip's worth of protocol drain. - const CLOSE_DRAIN: Duration = Duration::from_millis(250); - - /// Close the control stream and connection gracefully with a GOODBYE - /// frame, matching `macula_peering_conn.erl`'s `connected -> draining` - /// transition (minus the full drain-timeout bookkeeping, since this - /// crate isn't holding a supervisor to clean up). Every handle to this - /// session sees it end. - /// - /// `write_all(...).await` and `finish()` both only guarantee the data - /// was handed to quinn's own send-scheduling machinery, not that it - /// reached the peer -- `Connection::close` is abrupt and does not - /// wait for outstanding stream data to be delivered. Found live - /// 2026-08-29 in the Go port of this exact pattern - /// (macula-go's connection.Session.Close): a PUBLISH sent - /// immediately before Close intermittently never reached the peer, - /// root-caused to this race. Fixed proactively here before it was - /// independently rediscovered against this crate -- same doc - /// comment ("minus the drain-timeout bookkeeping"), same - /// write-then-immediately-abort-connection shape, so the same race - /// applies. Closing the stream via `finish()` first, then giving the - /// background sender a bounded window before hard-closing the - /// connection, mirrors the Erlang reference's own bounded-drain - /// approach. - pub async fn close(&self, reason: &str, detail: Option<&str>, identity: &KeyPair) { - let goodbye = frame::sign(frame::goodbye(reason, detail), identity); - self.inner.channel.close(&goodbye).await; - tokio::time::sleep(Self::CLOSE_DRAIN).await; - self.inner.connection.close(0u32.into(), reason.as_bytes()); - } - - /// The supervised counterpart to the bare [`publish`](Self::publish) - /// primitive, matching `macula_publisher.erl` in spirit: publishes - /// `pubsub.publish_started_v1` before the publish and - /// `pubsub.publish_completed_v1` after, both under `spec`'s own realm. - /// Fact-publish failures are silently discarded — matching - /// `macula_publisher.erl`'s own `publish/5` helper, which throws away - /// its result unconditionally (`_ = macula:publish(...), ok`). - /// - /// Unlike Erlang's version — a supervised worker process a caller can - /// kill mid-flight — this crate's bare `publish` is already a - /// synchronous, near-instant frame send (no ack on this wire, no - /// network round-trip to await), so there is no meaningful "cancel - /// before it starts" window worth a dedicated mechanism. Await this - /// directly, or wrap it in `tokio::select!`/`tokio::time::timeout` - /// yourself if you need to abandon it early — dropping a `Future` IS - /// real cancellation in Rust; Erlang has to simulate that by killing a - /// worker process. - pub async fn run_publisher( - &self, - spec: &frame::PublishSpec, - identity: &KeyPair, - announce: bool, - ) -> Result<(), SendError> { - let publish_id: [u8; 16] = rand::random(); - if announce { - let payload = Value::Map(vec![]) - .with_field("publish_id", Value::Bytes(publish_id.to_vec())) - .with_field("topic", Value::Bytes(spec.topic.as_bytes().to_vec())); - let fact = frame::PublishSpec::new( - "pubsub.publish_started_v1", - spec.realm, - identity.node_id(), - rand::random(), - payload, - now_ms(), - ); - let _ = self.publish(&fact, identity).await; - } - - let result = self.publish(spec, identity).await; - - if announce { - let payload = - Value::Map(vec![]).with_field("publish_id", Value::Bytes(publish_id.to_vec())); - let payload = match &result { - Ok(()) => payload.with_field("outcome", Value::text("completed")), - Err(e) => payload - .with_field("outcome", Value::text("failed")) - .with_field("reason", Value::text(e.to_string())), - }; - let fact = frame::PublishSpec::new( - "pubsub.publish_completed_v1", - spec.realm, - identity.node_id(), - rand::random(), - payload, - now_ms(), - ); - let _ = self.publish(&fact, identity).await; - } - - result - } - - /// The supervised counterpart to a bare [`Subscription`], matching - /// `macula_subscriber.erl` in spirit: subscribes once, then hands every - /// matching EVENT to `handler` until `stop` resolves, instead of - /// requiring the caller to hand-roll a receive loop. Closes the - /// subscription on return, including on cancellation, which sends - /// UNSUBSCRIBE when no other subscription on the session holds that - /// realm and topic. Other frames on the session never reach this loop: - /// the session's reader routes each one to whatever waits for it. - /// - /// Returns an error when the subscription falls behind - /// ([`RecvEventError::Overflow`]) or the session ends. - /// - /// No OTP pid to address a running subscriber by; `stop` plays that - /// role — matches [`keep_advertised`](Self::keep_advertised)'s own - /// cancellation shape exactly, not a new one. `handler` cannot itself - /// stop the loop (no return value) — by the same design `keep_advertised` - /// already established, where `on_error` can only report, not halt; - /// stopping is always external, via `stop`. - pub async fn run_subscriber( - &self, - spec: &frame::SubscribeSpec, - identity: &KeyPair, - stop: F, - mut handler: impl FnMut(frame::EventInfo), - ) -> Result<(), RunSubscriberError> - where - F: std::future::Future, - { - let mut subscription = self - .subscribe(spec, identity) - .await - .map_err(RunSubscriberError::Subscribe)?; - - tokio::pin!(stop); - let result = loop { - tokio::select! { - _ = &mut stop => break Ok(()), - received = subscription.recv_event(SUBSCRIBER_POLL_INTERVAL) => match received { - Ok(event) => handler(event), - Err(RecvEventError::Timeout) => {} - Err(e) => break Err(RunSubscriberError::Recv(e)), - }, - } - }; - - subscription.close().await; - result - } -} - -/// How long [`Session::run_subscriber`] waits on its subscription at a -/// time. Not a wire timeout: nothing is sent when it runs out, the loop just -/// waits again. -const SUBSCRIBER_POLL_INTERVAL: Duration = Duration::from_secs(3600); - -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock before 1970") - .as_millis() as u64 -} - -// RPC telemetry auto-facts, matching `macula_request.erl` (caller side: -// rpc.sent_v1/rpc.completed_v1) and `macula_response.erl` (provider side: -// rpc.received_v1/rpc.replied_v1) exactly -- same topic names, same -// `request_id` field (16 fresh random bytes per call, independent of the -// wire CALL frame's own `call_id` -- the reference tracks its own request -// lifecycle separately from the wire frame, and this does too), same realm -// as the call itself, fire-and-forget: each fact goes to the session's own -// writer, so it never costs the call or serve it describes any time and -// never fails it, matching `macula_response.erl`'s own -// `_ = macula:publish(...), ok` and `macula_request.erl`'s identical -// `publish/5` helper. A fact is dropped when 64 frames already wait for that -// writer. -// -// Always on, matching the reference's ACTUAL behavior on each side, not -// just a blanket claim -- checked directly rather than assumed: -// `macula_request.erl`'s `start_link/7` and `start_link_direct/8` both -// hardcode `true` literally at the tuple-construction call site; there is -// no `Opts` key or parameter that reaches it at all on the caller side. -// `macula_response.erl`'s `advertise/6` DOES read `announce` from its -// `Opts` map with a `true` default (`maps:get(announce, Opts, true)`) -- -// technically overridable -- but the one real caller in this workspace -// (`hecate_om_capabilities.erl`'s `advertise_opts/1`) never sets it to -// `false`. Matching Go's `macula-go` decision here: no toggle exposed -// on either side, since exposing one on `call`/`serve_one_call_gated` -- -// this crate's two most heavily used functions -- for an option nothing -// in the reference ecosystem actually flips would be a real-blast-radius -// signature change for no practical benefit. -const RPC_SENT_TOPIC: &str = "rpc.sent_v1"; -const RPC_COMPLETED_TOPIC: &str = "rpc.completed_v1"; -const RPC_RECEIVED_TOPIC: &str = "rpc.received_v1"; -const RPC_REPLIED_TOPIC: &str = "rpc.replied_v1"; - -fn request_id_payload(request_id: [u8; 16]) -> Value { - Value::Map(vec![]).with_field("request_id", Value::Bytes(request_id.to_vec())) -} - -/// A fact's PUBLISH, carrying its `publisher_sig`; the session's own writer -/// signs the envelope when it sends it. -fn rpc_fact(topic: &str, realm: [u8; 32], identity: &KeyPair, payload: Value) -> Value { - let spec = frame::PublishSpec::new( - topic, - realm, - identity.node_id(), - rand::random(), - payload, - now_ms(), - ); - frame::sign_publisher(frame::publish(&spec), identity) -} - -/// Matches `macula_request.erl`'s `outcome_fields/2`: `completed` (no Rust -/// error, not a bolt4 ERROR frame) or `failed` (either). Erlang -/// additionally has a `cancelled` outcome from its own -/// gen_server-cancellable `macula_request:cancel/1` -- this crate's plain -/// `call` has no cancellation concept independent of an ordinary -/// error/timeout at this layer, so that outcome is not reachable here and -/// is not fabricated (same reasoning Go's port already documented). -fn rpc_completed( - realm: [u8; 32], - identity: &KeyPair, - request_id: [u8; 16], - result: &Result, -) -> Value { - let payload = request_id_payload(request_id); - let payload = match result { - Err(e) => payload - .with_field("outcome", Value::text("failed")) - .with_field("reason", Value::text(e.to_string())), - Ok(frame::CallResponse::Error { name, .. }) => payload - .with_field("outcome", Value::text("failed")) - .with_field("reason", Value::text(name.clone())), - Ok(frame::CallResponse::Result { .. }) => { - payload.with_field("outcome", Value::text("completed")) - } - }; - rpc_fact(RPC_COMPLETED_TOPIC, realm, identity, payload) -} - -/// Matches `macula_response.erl`'s `outcome_fields/2`: `replied` (`{ok, -/// _}`) or `failed` (`{error, Reason}`). A handler panic is deliberately -/// NOT announced here at all -- matching the reference exactly, where a -/// crashing `Module:handle_request/2` crashes the whole per-request child -/// before its own `publish_replied/2` call is ever reached, so -/// `REQUEST_REPLIED` is never published for a crash there either. -fn rpc_replied( - realm: [u8; 32], - identity: &KeyPair, - request_id: [u8; 16], - handler_err: Option<&str>, -) -> Value { - let payload = request_id_payload(request_id); - let payload = match handler_err { - Some(reason) => payload - .with_field("outcome", Value::text("failed")) - .with_field("reason", Value::text(reason)), - None => payload.with_field("outcome", Value::text("replied")), - }; - rpc_fact(RPC_REPLIED_TOPIC, realm, identity, payload) -} - -/// Build the RESULT/ERROR reply for one inbound CALL — mirrors -/// `macula_station_link.erl`'s `handle_inbound_call/2` + -/// `safe_invoke_handler/4` exactly: `policy` is checked FIRST (a -/// rejection is BOLT#4 `unauthorized`, and `lookup`/a handler never run -/// at all); then a lookup miss is `unknown_next_peer`; the handler -/// running to completion produces a RESULT (`Ok`) or `unknown_error` with -/// `detail` (`Err`); a handler panic — caught via `tokio::spawn`, the -/// same "one transient task per call" shape the reference's own "one -/// process per call" uses — is `temporary_relay_failure`, with no -/// `detail`, matching the reference not sending one on a crash either. -/// -/// Announces `rpc.received_v1`/`rpc.replied_v1` around dispatch through -/// `session`'s own writer when `session` is `Some` -- `None` for the pure -/// dispatch-logic unit tests below, which deliberately exercise this -/// function with no network at all (mirrors `macula-go`'s identical -/// nil-session-safe `announceFact`). `rpc.received_v1` fires only after -/// `policy` and `lookup` both pass, matching `macula_response.erl`'s own -/// per-request child only starting once the raw advertise mechanism -/// already decided to dispatch to a real handler -- a UCAN-rejected or -/// unadvertised-procedure CALL announces neither fact. -/// The payload a CALL's handler receives: a map payload with `caller`, the -/// node id the CALL's signature was verified against, under `"caller"`, -/// replacing a `"caller"` the sender put there under a text or byte-string -/// key; any other payload unchanged. Mirrors -/// `macula_station_link:with_caller/2`. -pub(crate) fn with_caller(payload: Value, caller: [u8; 32]) -> Value { - match payload { - Value::Map(mut fields) => { - fields.retain(|(key, _)| !is_caller_key(key)); - fields.push((Value::text("caller"), Value::Bytes(caller.to_vec()))); - Value::Map(fields) - } - other => other, - } -} - -fn is_caller_key(key: &Value) -> bool { - match key { - Value::Text(text) => text == "caller", - Value::Bytes(bytes) => bytes.as_slice() == b"caller", - _ => false, - } -} - -pub(crate) async fn build_call_reply( - call_info: frame::CallInfo, - lookup: &L, - policy: &P, - identity: &KeyPair, - session: Option<&Session>, -) -> Value -where - L: Fn(&[u8; 32], &str) -> Option, - P: Fn(&[u8; 32], &str) -> crate::ucan::Policy, -{ - let self_pub = identity.node_id(); - - if policy(&call_info.realm, &call_info.procedure) - .check(&call_info.ucan_token, &call_info.caller) - .is_err() - { - return frame::call_error(&frame::CallErrorSpec::new( - call_info.call_id, - bolt4::Code::Unauthorized, - self_pub, - )); - } - - let Some(handler) = lookup(&call_info.realm, &call_info.procedure) else { - return frame::call_error(&frame::CallErrorSpec::new( - call_info.call_id, - bolt4::Code::UnknownNextPeer, - self_pub, - )); - }; - - let request_id: [u8; 16] = rand::random(); - if let Some(session) = session { - session.inner.channel.hand_off(rpc_fact( - RPC_RECEIVED_TOPIC, - call_info.realm, - identity, - request_id_payload(request_id), - )); - } - - let payload = with_caller(call_info.payload, call_info.caller); - let outcome = tokio::spawn(async move { handler(payload).await }).await; - match outcome { - Ok(Ok(value)) => { - if let Some(session) = session { - session.inner.channel.hand_off(rpc_replied( - call_info.realm, - identity, - request_id, - None, - )); - } - frame::result(&frame::ResultSpec::new(call_info.call_id, value, self_pub)) - } - Ok(Err(reason)) => { - if let Some(session) = session { - session.inner.channel.hand_off(rpc_replied( - call_info.realm, - identity, - request_id, - Some(&reason), - )); - } - let mut spec = - frame::CallErrorSpec::new(call_info.call_id, bolt4::Code::UnknownError, self_pub); - spec.detail = Some(reason); - frame::call_error(&spec) - } - Err(_join_error) => frame::call_error(&frame::CallErrorSpec::new( - call_info.call_id, - bolt4::Code::TemporaryRelayFailure, - self_pub, - )), - } -} - -#[cfg(test)] -mod ucan_gating_tests { - //! Proves `serve_one_call_gated`'s policy wiring end-to-end WITHOUT a - //! network — `build_call_reply` is a plain async function of - //! `(CallInfo, lookup, policy, self_pub)`, so its dispatch/reply logic - //! is fully testable in isolation. Mirrors `macula-go`'s own 4 - //! connection-level UCAN-gating unit tests (`serve_ucan_test.go`). - use super::*; - use crate::identity::KeyPair; - use crate::ucan::{self, Policy}; - - fn call_info(ucan_token: Vec) -> frame::CallInfo { - frame::CallInfo { - call_id: [1; 16], - procedure: "test.proc".into(), - realm: [0; 32], - payload: Value::Null, - deadline_ms: 0, - caller: [2; 32], - ucan_token, - } - } - - fn never_called_lookup() -> impl Fn(&[u8; 32], &str) -> Option { - |_, _| panic!("handler lookup must not run when policy rejects the call") - } - - fn echo_lookup() -> impl Fn(&[u8; 32], &str) -> Option { - |_, _| { - Some(Arc::new(|payload: Value| { - Box::pin(async move { Ok(payload) }) - })) - } - } - - #[tokio::test] - async fn open_policy_never_gates_dispatch() { - let identity = KeyPair::generate(); - let reply = build_call_reply( - call_info(Vec::new()), - &echo_lookup(), - &|_, _| Policy::open(), - &identity, - None, - ) - .await; - assert!(matches!( - frame::parse_call_response(&reply), - Ok(frame::CallResponse::Result { .. }) - )); - } - - #[tokio::test] - async fn required_policy_refuses_a_call_with_no_token_before_lookup_runs() { - let id = KeyPair::generate(); - let identity = KeyPair::generate(); - let reply = build_call_reply( - call_info(Vec::new()), - &never_called_lookup(), - &move |_, _| Policy::required(id.node_id()), - &identity, - None, - ) - .await; - match frame::parse_call_response(&reply) { - Ok(frame::CallResponse::Error { code, .. }) => { - assert_eq!(code, bolt4::Code::Unauthorized as u8) - } - other => panic!("expected an Unauthorized ERROR frame, got {other:?}"), - } - } - - #[tokio::test] - async fn required_policy_refuses_a_token_from_the_wrong_issuer_before_lookup_runs() { - let required_issuer = KeyPair::generate(); - let impostor = KeyPair::generate(); - let bad_token = ucan::create( - "did:iss", - "did:aud", - vec![], - &impostor, - ucan::CreateOpts::default(), - ) - .unwrap(); - let identity = KeyPair::generate(); - let reply = build_call_reply( - call_info(bad_token), - &never_called_lookup(), - &move |_, _| Policy::required(required_issuer.node_id()), - &identity, - None, - ) - .await; - match frame::parse_call_response(&reply) { - Ok(frame::CallResponse::Error { code, .. }) => { - assert_eq!(code, bolt4::Code::Unauthorized as u8) - } - other => panic!("expected an Unauthorized ERROR frame, got {other:?}"), - } - } - - #[tokio::test] - async fn required_policy_lets_a_valid_token_reach_the_handler() { - let id = KeyPair::generate(); - let good_token = ucan::create( - "did:iss", - &hex::encode(call_info(Vec::new()).caller), - vec![], - &id, - ucan::CreateOpts::default(), - ) - .unwrap(); - let identity = KeyPair::generate(); - let reply = build_call_reply( - call_info(good_token), - &echo_lookup(), - &move |_, _| Policy::required(id.node_id()), - &identity, - None, - ) - .await; - assert!(matches!( - frame::parse_call_response(&reply), - Ok(frame::CallResponse::Result { .. }) - )); - } - - // A handler receives the verified caller in a map payload. The names - // match macula-go's. - - async fn payload_seen_by_the_handler(payload: Value, caller: [u8; 32]) -> Value { - let info = frame::CallInfo { - payload, - caller, - ..call_info(Vec::new()) - }; - let reply = build_call_reply( - info, - &echo_lookup(), - &|_, _| Policy::open(), - &KeyPair::generate(), - None, - ) - .await; - match frame::parse_call_response(&reply) { - Ok(frame::CallResponse::Result { payload, .. }) => payload, - other => panic!("expected a RESULT, got {other:?}"), - } - } - - #[tokio::test] - async fn an_inbound_call_threads_its_caller_into_the_payload() { - let caller = KeyPair::generate().node_id(); - let payload = Value::Map(vec![(Value::text("n"), Value::Int(21))]); - - let seen = payload_seen_by_the_handler(payload, caller).await; - - assert_eq!(seen.get("caller"), Some(&Value::Bytes(caller.to_vec()))); - assert_eq!(seen.get("n"), Some(&Value::Int(21))); - } - - #[tokio::test] - async fn a_caller_the_sender_put_in_the_payload_is_replaced_by_the_verified_caller() { - let (caller, claimed) = (KeyPair::generate().node_id(), KeyPair::generate().node_id()); - let payload = Value::Map(vec![ - (Value::text("caller"), Value::Bytes(claimed.to_vec())), - ( - Value::Bytes(b"caller".to_vec()), - Value::Bytes(claimed.to_vec()), - ), - ]); - - let seen = payload_seen_by_the_handler(payload, caller).await; - - assert_eq!( - seen, - Value::Map(vec![(Value::text("caller"), Value::Bytes(caller.to_vec()))]) - ); - } - - #[tokio::test] - async fn a_non_map_payload_carries_no_caller() { - let caller = KeyPair::generate().node_id(); - - let seen = payload_seen_by_the_handler(Value::text("hello"), caller).await; - - assert_eq!(seen, Value::text("hello")); - } - - // The provider side of an inbound CALL: a gated policy accepts a token - // only from the caller it was minted for. The names match macula-go's - // connection/serve_caller_test.go. That a CALL reaches serving only when - // its signature verifies against the caller it names is checked by the - // session's reader; see control_channel.rs. - - fn call_info_from(caller: [u8; 32], ucan_token: Vec) -> frame::CallInfo { - frame::CallInfo { - caller, - ..call_info(ucan_token) - } - } - - fn token_for(issuer: &KeyPair, audience: &str) -> Vec { - ucan::create( - "did:iss", - audience, - vec![], - issuer, - ucan::CreateOpts::default(), - ) - .unwrap() - } - - fn is_unauthorized(reply: &Value) -> bool { - matches!( - frame::parse_call_response(reply), - Ok(frame::CallResponse::Error { code, .. }) if code == bolt4::Code::Unauthorized as u8 - ) - } - - #[tokio::test] - async fn build_call_reply_gated_policy_refuses_a_token_presented_by_another_caller() { - let (issuer, audience, presenter) = ( - KeyPair::generate(), - KeyPair::generate(), - KeyPair::generate(), - ); - let token = token_for(&issuer, &hex::encode(audience.node_id())); - - let reply = build_call_reply( - call_info_from(presenter.node_id(), token), - &never_called_lookup(), - &move |_, _| Policy::required(issuer.node_id()), - &KeyPair::generate(), - None, - ) - .await; - - assert!( - is_unauthorized(&reply), - "{:?}", - frame::parse_call_response(&reply) - ); - } - - #[tokio::test] - async fn build_call_reply_gated_policy_accepts_a_token_from_its_audience() { - let (issuer, caller) = (KeyPair::generate(), KeyPair::generate()); - let token = token_for(&issuer, &hex::encode(caller.node_id())); - - let reply = build_call_reply( - call_info_from(caller.node_id(), token), - &echo_lookup(), - &move |_, _| Policy::required(issuer.node_id()), - &KeyPair::generate(), - None, - ) - .await; - - assert!(matches!( - frame::parse_call_response(&reply), - Ok(frame::CallResponse::Result { .. }) - )); - } - - #[tokio::test] - async fn build_call_reply_gated_policy_refuses_a_token_without_audience() { - let (issuer, caller) = (KeyPair::generate(), KeyPair::generate()); - let token = token_for(&issuer, ""); - - let reply = build_call_reply( - call_info_from(caller.node_id(), token), - &never_called_lookup(), - &move |_, _| Policy::required(issuer.node_id()), - &KeyPair::generate(), - None, - ) - .await; - - assert!( - is_unauthorized(&reply), - "{:?}", - frame::parse_call_response(&reply) - ); - } -} diff --git a/src/content.rs b/src/content.rs deleted file mode 100644 index 35c09da..0000000 --- a/src/content.rs +++ /dev/null @@ -1,398 +0,0 @@ -//! Content sharing (§12 of `plans/PLAN_WIRE_PROTOCOL.md`): put/get by -//! content-address, over a dedicated QUIC stream — ordinary CALL/RESULT -//! (§6.4) against four well-known `_content.*` procedures, ported from -//! `macula_content_transfer.erl`. Not a separate wire protocol: nothing -//! here is new frame types, just calls a normal [`crate::connection::Session`] -//! could already make, sent on a stream opened via -//! [`Session::open_dedicated_stream`](crate::connection::Session::open_dedicated_stream) -//! instead of the control stream. -//! -//! **Deliberate v1 simplification (documented per spec §12.2):** chunked -//! transfers here run strictly sequentially, one `_content.put_block` / -//! `_content.get_block` in flight at a time on the single dedicated -//! stream this module opens — not the reference's parallel multi-lane -//! algorithm (round-robin chunks across up to 4 concurrent streams). -//! Multi-lane parallelism is a throughput optimization, not a -//! correctness requirement: every `_content.*` call, the MCID scheme, -//! and the manifest wire format are identical either way, so this v1 -//! client interoperates fully with a station built to serve a -//! parallel-lane peer, and lanes can be added later purely as a -//! performance improvement with no wire change. -//! -//! Sequential retrieval has one incidental upside over the reference: -//! chunks arrive and get appended in index order for free, so there's no -//! need for the reference's "accumulate into a map keyed by index, then -//! reassemble" step. - -use std::time::Duration; - -use crate::bolt4; -use crate::cbor::Value; -use crate::connection::{FrameStream, Session, StreamCallError}; -use crate::identity::KeyPair; -use crate::manifest::{self, Manifest, Mcid}; - -/// Reserved realm sentinel for all `_content.*` calls — 32 zero bytes, -/// distinct from any real realm (`plans/PLAN_WIRE_PROTOCOL.md` §12.1). -pub const CONTENT_REALM: [u8; 32] = [0u8; 32]; - -const PUT_BLOCK_PROC: &str = "_content.put_block"; -const GET_BLOCK_PROC: &str = "_content.get_block"; -const PUT_MANIFEST_PROC: &str = "_content.put_manifest"; -const GET_MANIFEST_PROC: &str = "_content.get_manifest"; - -/// Matches `CONTENT_BLOCK_TIMEOUT_MS` in `macula_content_transfer.erl`. -const BLOCK_TIMEOUT: Duration = Duration::from_secs(15); -/// Matches `CONTENT_MANIFEST_TIMEOUT_MS`. -const MANIFEST_TIMEOUT: Duration = Duration::from_secs(5); - -/// Matches §12.2's retry policy: up to 3 attempts total, 200ms backoff -/// between them, only for a BOLT#4 code flagged retryable (§9). -const MAX_ATTEMPTS: u32 = 3; -const RETRY_BACKOFF: Duration = Duration::from_millis(200); - -#[derive(Debug)] -pub enum PutError { - /// Opening the dedicated stream itself failed (e.g. the connection - /// is already dead) — never got as far as making a call. - OpenStream(quinn::ConnectionError), - Call(StreamCallError), - /// The station rejected the call with a BOLT#4 ERROR. - Remote { - code: u8, - name: String, - detail: Option, - }, - /// A RESULT arrived but its payload wasn't one of the shapes this - /// procedure is documented to return. - UnexpectedReply(Value), - /// The station recomputed the block's hash and it didn't match the - /// MCID the caller sent — the block was not stored. - HashMismatch, -} - -impl std::fmt::Display for PutError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PutError::OpenStream(e) => write!(f, "opening a dedicated stream: {e}"), - PutError::Call(e) => write!(f, "{e}"), - PutError::Remote { code, name, detail } => { - write!(f, "station returned error {code} ({name}): {detail:?}") - } - PutError::UnexpectedReply(v) => write!(f, "unexpected reply shape: {v:?}"), - PutError::HashMismatch => write!(f, "station reported hash_mismatch"), - } - } -} - -impl std::error::Error for PutError {} - -#[derive(Debug)] -pub enum GetError { - /// Opening the dedicated stream itself failed (e.g. the connection - /// is already dead) — never got as far as making a call. - OpenStream(quinn::ConnectionError), - Call(StreamCallError), - Remote { - code: u8, - name: String, - detail: Option, - }, - UnexpectedReply(Value), - NotFound, - ManifestDecode(manifest::FromWireError), - /// A fetched block or reassembled blob didn't hash to the MCID it - /// was fetched under — see the module-level note in §12.1: a - /// station may only be relaying content it doesn't itself store, so - /// its answer is never trusted without this client-side check. - HashMismatch, - Verify(manifest::VerifyError), -} - -impl std::fmt::Display for GetError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - GetError::OpenStream(e) => write!(f, "opening a dedicated stream: {e}"), - GetError::Call(e) => write!(f, "{e}"), - GetError::Remote { code, name, detail } => { - write!(f, "station returned error {code} ({name}): {detail:?}") - } - GetError::UnexpectedReply(v) => write!(f, "unexpected reply shape: {v:?}"), - GetError::NotFound => write!(f, "station reported not_found"), - GetError::ManifestDecode(e) => write!(f, "decoding the fetched manifest: {e}"), - GetError::HashMismatch => write!(f, "fetched content does not hash to its MCID"), - GetError::Verify(e) => write!(f, "reassembled content failed verification: {e}"), - } - } -} - -impl std::error::Error for GetError {} - -/// Store `data`, returning the MCID it's now addressable by. -/// -/// `name` is attached to the manifest when `data` is large enough to be -/// chunked; a single block (`data.len() <= manifest::DEFAULT_CHUNK_SIZE`) -/// is addressed purely by content hash and carries no name at all, -/// matching `macula_content_transfer:put_single_block/3` — `name` is -/// silently unused on that path, not an oversight. -pub async fn put( - session: &Session, - data: &[u8], - name: impl Into, - identity: &KeyPair, -) -> Result { - let stream = session - .open_dedicated_stream() - .await - .map_err(PutError::OpenStream)?; - put_on(stream, data, name, identity).await -} - -/// [`put`], over a dedicated stream already open to the station. -pub(crate) async fn put_on( - mut stream: FrameStream, - data: &[u8], - name: impl Into, - identity: &KeyPair, -) -> Result { - if data.len() <= manifest::DEFAULT_CHUNK_SIZE { - let mcid = manifest::block_mcid(data); - put_block(&mut stream, &mcid, data, identity).await?; - return Ok(mcid); - } - - let opts = manifest::CreateOptions { - name: name.into(), - ..manifest::CreateOptions::default() - }; - let (manifest, chunks) = manifest::create(data, &opts); - for (index, chunk) in chunks.iter().enumerate() { - let chunk_mcid = manifest::chunk_mcid(&manifest, index) - .expect("index is in range: it came from iterating manifest.create's own chunks"); - put_block(&mut stream, &chunk_mcid, chunk, identity).await?; - } - put_manifest(&mut stream, &manifest, identity).await?; - Ok(manifest.mcid) -} - -/// Fetch and verify the content addressed by `mcid`. -pub async fn get(session: &Session, mcid: Mcid, identity: &KeyPair) -> Result, GetError> { - let stream = session - .open_dedicated_stream() - .await - .map_err(GetError::OpenStream)?; - get_on(stream, mcid, identity).await -} - -/// [`get`], over a dedicated stream already open to the station. -pub(crate) async fn get_on( - mut stream: FrameStream, - mcid: Mcid, - identity: &KeyPair, -) -> Result, GetError> { - if !manifest::mcid_is_chunked(&mcid) { - let data = get_block(&mut stream, &mcid, identity).await?; - if manifest::block_mcid(&data) != mcid { - return Err(GetError::HashMismatch); - } - return Ok(data); - } - - let manifest = get_manifest(&mut stream, &mcid, identity).await?; - // Deliberately NOT `Vec::with_capacity(manifest.size as usize)`: - // `manifest.size` is a bare claim from whichever peer served this - // manifest, unverified until every chunk is in hand and - // `manifest::verify` runs below — a single small malicious manifest - // could otherwise claim an enormous size and trigger an immediate, - // unbounded allocation attempt before a single byte of real content - // has been fetched. Growing the buffer as genuinely-received, - // individually-hash-verified chunks arrive bounds memory use to - // what has actually, legitimately come off the wire. - let mut data = Vec::new(); - for index in 0..manifest.chunk_count { - let chunk_mcid = manifest::chunk_mcid(&manifest, index) - .expect("index < manifest.chunk_count, so manifest.chunks[index] exists"); - let chunk = get_block(&mut stream, &chunk_mcid, identity).await?; - if manifest::block_mcid(&chunk) != chunk_mcid { - return Err(GetError::HashMismatch); - } - data.extend_from_slice(&chunk); - } - manifest::verify(&manifest, &data).map_err(GetError::Verify)?; - Ok(data) -} - -async fn put_block( - stream: &mut FrameStream, - mcid: &Mcid, - bytes: &[u8], - identity: &KeyPair, -) -> Result<(), PutError> { - let payload = Value::Map(vec![ - (Value::text("mcid"), Value::Bytes(mcid.to_vec())), - (Value::text("payload"), Value::Bytes(bytes.to_vec())), - ]); - let response = call_with_retry(stream, PUT_BLOCK_PROC, payload, BLOCK_TIMEOUT, identity) - .await - .map_err(PutError::Call)?; - match response { - crate::frame::CallResponse::Result { payload, .. } => match payload { - Value::Text(t) if t == "ok" => Ok(()), - Value::Text(t) if t == "hash_mismatch" => Err(PutError::HashMismatch), - other => Err(PutError::UnexpectedReply(other)), - }, - crate::frame::CallResponse::Error { - code, name, detail, .. - } => Err(PutError::Remote { code, name, detail }), - } -} - -async fn put_manifest( - stream: &mut FrameStream, - manifest: &Manifest, - identity: &KeyPair, -) -> Result<(), PutError> { - let payload = Value::Map(vec![(Value::text("manifest"), manifest::to_wire(manifest))]); - let response = call_with_retry( - stream, - PUT_MANIFEST_PROC, - payload, - MANIFEST_TIMEOUT, - identity, - ) - .await - .map_err(PutError::Call)?; - match response { - crate::frame::CallResponse::Result { payload, .. } => match payload { - Value::Text(t) if t == "ok" => Ok(()), - other => Err(PutError::UnexpectedReply(other)), - }, - crate::frame::CallResponse::Error { - code, name, detail, .. - } => Err(PutError::Remote { code, name, detail }), - } -} - -async fn get_block( - stream: &mut FrameStream, - mcid: &Mcid, - identity: &KeyPair, -) -> Result, GetError> { - let payload = Value::Map(vec![(Value::text("mcid"), Value::Bytes(mcid.to_vec()))]); - let response = call_with_retry(stream, GET_BLOCK_PROC, payload, BLOCK_TIMEOUT, identity) - .await - .map_err(GetError::Call)?; - match response { - crate::frame::CallResponse::Result { payload, .. } => match payload { - Value::Bytes(b) => Ok(b), - Value::Text(t) if t == "not_found" => Err(GetError::NotFound), - other => Err(GetError::UnexpectedReply(other)), - }, - crate::frame::CallResponse::Error { - code, name, detail, .. - } => Err(GetError::Remote { code, name, detail }), - } -} - -async fn get_manifest( - stream: &mut FrameStream, - mcid: &Mcid, - identity: &KeyPair, -) -> Result { - let payload = Value::Map(vec![(Value::text("mcid"), Value::Bytes(mcid.to_vec()))]); - let response = call_with_retry( - stream, - GET_MANIFEST_PROC, - payload, - MANIFEST_TIMEOUT, - identity, - ) - .await - .map_err(GetError::Call)?; - match response { - crate::frame::CallResponse::Result { payload, .. } => match payload { - Value::Map(_) => manifest::from_wire(&payload).map_err(GetError::ManifestDecode), - Value::Text(t) if t == "not_found" => Err(GetError::NotFound), - other => Err(GetError::UnexpectedReply(other)), - }, - crate::frame::CallResponse::Error { - code, name, detail, .. - } => Err(GetError::Remote { code, name, detail }), - } -} - -/// Send one `_content.*` CALL, retrying per §12.2's policy: up to -/// [`MAX_ATTEMPTS`] total, [`RETRY_BACKOFF`] between them, only when the -/// prior attempt's ERROR carries a BOLT#4 code flagged -/// [retryable](bolt4::Code::is_retryable). A non-retryable ERROR, or a -/// RESULT (whatever its payload turns out to mean to the caller), both -/// return on the first attempt. -async fn call_with_retry( - stream: &mut FrameStream, - procedure: &str, - payload: Value, - timeout: Duration, - identity: &KeyPair, -) -> Result { - let mut attempt = 0; - loop { - attempt += 1; - let deadline_ms = (now_ms() + timeout.as_millis() as u64) as i128; - let outcome = stream - .call( - procedure, - CONTENT_REALM, - payload.clone(), - deadline_ms, - identity, - timeout, - ) - .await; - - let should_retry = attempt < MAX_ATTEMPTS - && matches!( - &outcome, - Ok(crate::frame::CallResponse::Error { code, .. }) - if bolt4::Code::from_u8(*code).is_some_and(bolt4::Code::is_retryable) - ); - if !should_retry { - return outcome; - } - tokio::time::sleep(RETRY_BACKOFF).await; - } -} - -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock after epoch") - .as_millis() as u64 -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn small_data_addresses_as_a_single_block() { - let data = vec![7u8; 100]; - assert!(data.len() <= manifest::DEFAULT_CHUNK_SIZE); - let mcid = manifest::block_mcid(&data); - assert!(!manifest::mcid_is_chunked(&mcid)); - } - - #[test] - fn large_data_would_address_as_a_manifest() { - let data = vec![7u8; manifest::DEFAULT_CHUNK_SIZE + 1]; - let opts = manifest::CreateOptions::default(); - let (manifest, chunks) = manifest::create(&data, &opts); - assert!(chunks.len() > 1); - assert!(manifest::mcid_is_chunked(&manifest.mcid)); - } - - #[test] - fn call_with_retry_backoff_matches_the_spec() { - assert_eq!(MAX_ATTEMPTS, 3); - assert_eq!(RETRY_BACKOFF, Duration::from_millis(200)); - } -} diff --git a/src/control_channel.rs b/src/control_channel.rs deleted file mode 100644 index 6bb89ad..0000000 --- a/src/control_channel.rs +++ /dev/null @@ -1,1668 +0,0 @@ -//! One session's control stream: a single reader that routes every frame, -//! and writers that take turns. [`Session`](crate::connection::Session) is -//! the public face of this; the machinery lives here. -//! -//! The reader hands a RESULT or ERROR to the call waiting on its `call_id`, -//! an EVENT to every subscription whose realm is the event's and whose topic -//! pattern matches it (the station's rule: both split on "/", equal segment -//! counts, and "*" matches exactly one whole segment), and a CALL signed by -//! the caller it names to the inbound call queue. GOODBYE, HELLO or CONNECT -//! after the handshake, and a frame that can't be decoded end the session. -//! Any other frame is dropped and counted by type, with at most one log line -//! per type per minute. -//! -//! Writers take turns. Waiting for a turn is bounded by the caller's own -//! deadline: a call's timeout, and the send timeout for every other frame. -//! A write in progress is bounded by the send timeout, and one that stalls -//! past it ends the session. The reader never waits on a write: the frames a -//! session sends on its own account, the replies the reader makes and the -//! RPC telemetry facts, go to a writer of their own, and one is dropped when -//! 64 already wait there. -//! -//! When the session ends, every waiting call, every subscription, the -//! inbound call queue and every later operation report why, the end is -//! logged once with that reason and both node ids, and the session's -//! `on_ended` runs once. - -use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError}; -use std::time::Duration; - -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -use tokio::sync::{mpsc, oneshot, watch}; -use tokio::task::JoinHandle; - -use crate::bolt4; -use crate::cbor::Value; -use crate::frame::{self, CallInfo, CallResponse, CallSpec, Decoded, EventInfo, SubscribeSpec}; -use crate::identity::KeyPair; -use drop_warning::{DropWarnings, Kind, Reason, Subject}; - -pub(crate) type BoxRead = Box; -pub(crate) type BoxWrite = Box; - -/// Runs once when a session ends, with why and whether it was closed here. -pub(crate) type OnEnded = Box; - -/// How many events one subscription holds before it falls behind. -pub(crate) const EVENT_QUEUE_CAPACITY: usize = 256; -/// How many inbound CALLs wait to be served before an extra one is refused. -pub(crate) const CALL_QUEUE_CAPACITY: usize = 64; -/// How many frames a session's own writer holds before it drops one. -pub(crate) const HAND_OFF_CAPACITY: usize = 64; -/// How long a send waits for its turn to write, and how long any write may -/// take before it ends the session. -pub(crate) const SEND_TIMEOUT: Duration = Duration::from_secs(30); - -const LOG_INTERVAL: Duration = Duration::from_secs(60); -const READ_CHUNK: usize = 64 * 1024; - -/// Why a session ended. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SessionEndReason { - /// The station sent GOODBYE. - Goodbye { - reason: String, - detail: Option, - }, - /// The station sent HELLO or CONNECT, which only belong to the - /// handshake, after it. - ProtocolViolation { frame_type: String }, - /// A write on the control stream stalled for longer than the send - /// timeout, so a frame may be half written. - SendTimeout, - /// The station sent a frame that couldn't be decoded, so nothing after it - /// could be read in step. - Malformed(String), - /// The control stream ended or failed. - StreamFailed(String), - /// The session was closed or dropped here. - Closed, -} - -impl std::fmt::Display for SessionEndReason { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SessionEndReason::Goodbye { - reason, - detail: None, - } => write!(f, "the station said goodbye: {reason}"), - SessionEndReason::Goodbye { - reason, - detail: Some(detail), - } => write!(f, "the station said goodbye: {reason} ({detail})"), - SessionEndReason::ProtocolViolation { frame_type } => { - write!(f, "the station sent {frame_type} after the handshake") - } - SessionEndReason::SendTimeout => write!( - f, - "a write on the control stream stalled past the send timeout" - ), - SessionEndReason::Malformed(why) => { - write!( - f, - "the station sent a frame that could not be decoded: {why}" - ) - } - SessionEndReason::StreamFailed(why) => write!(f, "the control stream failed: {why}"), - SessionEndReason::Closed => write!(f, "the session was closed"), - } - } -} - -/// Why a frame couldn't be sent on a session. -#[derive(Debug)] -pub enum SendError { - /// No turn to write came within the send timeout. The frame was not - /// sent, and the session carries on. - Timeout, - /// This frame's own write stalled past the send timeout, and the session - /// ended. - SendTimeout, - /// The session had ended, or ended while the frame waited for its turn. - SessionEnded(SessionEndReason), - Encode(frame::EncodeFrameError), - /// Writing to the control stream failed. - Write(std::io::Error), -} - -impl std::fmt::Display for SendError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SendError::Timeout => write!( - f, - "no turn to write came within the send timeout, so the frame was not sent" - ), - SendError::SendTimeout => write!( - f, - "the write stalled past the send timeout, and the session ended" - ), - SendError::SessionEnded(reason) => write!(f, "the session has ended: {reason}"), - SendError::Encode(e) => write!(f, "encoding the frame: {e}"), - SendError::Write(e) => write!(f, "writing to the control stream: {e}"), - } - } -} - -impl std::error::Error for SendError {} - -/// Why a call on a session got no reply. -#[derive(Debug)] -pub enum CallError { - /// The call's own timeout ran out. `write_started` is false when the - /// CALL was still waiting for its turn to write, so it was never sent, - /// and true once its write had started, so the station may have it. A - /// reply that arrives later is counted as unrouted. - Timeout { - write_started: bool, - }, - /// The session ended before a reply came. `write_started` means the same - /// as for [`CallError::Timeout`]. - SessionEnded { - reason: SessionEndReason, - write_started: bool, - }, - /// This call's own write stalled past the send timeout, and the session - /// ended. The station may have part of the CALL. - SendTimeout, - Encode(frame::EncodeFrameError), - /// Writing the CALL failed. - Write(std::io::Error), - /// A reply carried this call's `call_id` but wasn't a RESULT or ERROR. - MalformedReply(frame::ParseCallResponseError), -} - -impl CallError { - /// Whether the CALL was never sent, so trying it elsewhere can't run it - /// twice. - pub fn not_sent(&self) -> bool { - matches!( - self, - CallError::Timeout { - write_started: false - } | CallError::SessionEnded { - write_started: false, - .. - } | CallError::Encode(_) - ) - } -} - -impl std::fmt::Display for CallError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - CallError::Timeout { - write_started: false, - } => write!( - f, - "no turn to write the CALL came in time, so it was not sent" - ), - CallError::Timeout { - write_started: true, - } => write!(f, "timed out waiting for a RESULT or ERROR"), - CallError::SessionEnded { - reason, - write_started: false, - } => write!(f, "the session ended before the CALL was sent: {reason}"), - CallError::SessionEnded { - reason, - write_started: true, - } => write!(f, "the session ended while awaiting a reply: {reason}"), - CallError::SendTimeout => write!( - f, - "the CALL's write stalled past the send timeout, and the session ended" - ), - CallError::Encode(e) => write!(f, "encoding the CALL: {e}"), - CallError::Write(e) => write!(f, "writing the CALL: {e}"), - CallError::MalformedReply(e) => write!(f, "the reply was malformed: {e}"), - } - } -} - -impl std::error::Error for CallError {} - -/// Why a subscription produced no event. -#[derive(Debug)] -pub enum RecvEventError { - /// No event arrived in time. - Timeout, - /// This subscription fell more than 256 events behind. The events it had - /// queued were read first; it receives nothing more, but keeps the - /// station subscribed until it is closed, so a replacement subscribed - /// first takes over without a gap. The session carries on. - Overflow, - /// The session ended. - SessionEnded(SessionEndReason), -} - -impl std::fmt::Display for RecvEventError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - RecvEventError::Timeout => write!(f, "timed out waiting for an event"), - RecvEventError::Overflow => write!( - f, - "the subscription fell more than {EVENT_QUEUE_CAPACITY} events behind" - ), - RecvEventError::SessionEnded(reason) => write!(f, "the session has ended: {reason}"), - } - } -} - -impl std::error::Error for RecvEventError {} - -/// The calls waiting for their reply, by call id. -type PendingCalls = - HashMap<[u8; 16], oneshot::Sender>>; - -/// The routing and writing state of one session's control stream, shared by -/// the session's handles, its reader task and its hand-off writer. -pub(crate) struct Channel { - identity: KeyPair, - station: [u8; 32], - send_timeout: Duration, - /// Holding this lock is the turn to write. - writer: Arc>, - calls: Mutex, - subscriptions: Mutex>, - /// Subscribing and closing take turns across the count change and the - /// SUBSCRIBE or UNSUBSCRIBE it sends, so the frames reach the station in - /// the order the counts changed. - subscription_turn: tokio::sync::Mutex<()>, - next_subscription: AtomicU64, - inbound_tx: Mutex>>, - inbound_rx: tokio::sync::Mutex>, - hand_off: Mutex>>, - unrouted: Mutex>, - reported: Mutex)>>, - /// The bounded warnings for dropped CALLs and replies and refused streams. - drop_warnings: DropWarnings, - ended: OnceLock, - ended_tx: watch::Sender, - on_ended: Mutex>, -} - -/// One subscription as the reader sees it. `events` is dropped when the -/// subscription overflows or the session ends, which is how its receiver -/// learns it gets nothing more. -struct Entry { - id: u64, - realm: [u8; 32], - topic: String, - subscriber: [u8; 32], - events: Option>, - overflowed: Arc, -} - -impl Entry { - fn holds(&self, realm: &[u8; 32], topic: &str) -> bool { - self.realm == *realm && self.topic == topic - } -} - -/// Why a writer got no turn. -enum Refused { - Timeout, - Ended(SessionEndReason), -} - -/// How a write that got its turn went. -enum Written { - Whole, - Stalled, - Failed(std::io::Error), -} - -// A panic elsewhere while a lock was held leaves the data itself intact. -fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { - mutex.lock().unwrap_or_else(PoisonError::into_inner) -} - -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -fn text_field(frame: &Value, key: &str) -> Option { - match frame.get(key)? { - Value::Text(text) => Some(text.clone()), - Value::Bytes(bytes) => String::from_utf8(bytes.clone()).ok(), - _ => None, - } -} - -/// The station's topic rule: both split on "/", equal segment counts, and -/// each pattern segment equal to the topic's or "*", which matches exactly -/// one whole segment. -pub(crate) fn topic_matches(pattern: &str, topic: &str) -> bool { - let pattern: Vec<&str> = pattern.split('/').collect(); - let topic: Vec<&str> = topic.split('/').collect(); - pattern.len() == topic.len() - && pattern - .iter() - .zip(&topic) - .all(|(segment, actual)| *segment == "*" || segment == actual) -} - -impl Channel { - /// Starts the reader and the hand-off writer on a control stream whose - /// handshake is done. `leftover` is what the handshake read past HELLO. - /// `identity` signs the frames the session sends on its own account. - pub(crate) fn start( - reader: BoxRead, - leftover: Vec, - writer: BoxWrite, - identity: KeyPair, - station: [u8; 32], - send_timeout: Duration, - on_ended: OnEnded, - ) -> Arc { - let (inbound_tx, inbound_rx) = mpsc::channel(CALL_QUEUE_CAPACITY); - let (hand_off_tx, hand_off_rx) = mpsc::channel(HAND_OFF_CAPACITY); - let (ended_tx, _) = watch::channel(false); - let channel = Arc::new(Channel { - identity, - station, - send_timeout, - writer: Arc::new(tokio::sync::Mutex::new(writer)), - calls: Mutex::new(HashMap::new()), - subscriptions: Mutex::new(Vec::new()), - subscription_turn: tokio::sync::Mutex::new(()), - next_subscription: AtomicU64::new(0), - inbound_tx: Mutex::new(Some(inbound_tx)), - inbound_rx: tokio::sync::Mutex::new(inbound_rx), - hand_off: Mutex::new(Some(hand_off_tx)), - unrouted: Mutex::new(HashMap::new()), - reported: Mutex::new(HashMap::new()), - drop_warnings: DropWarnings::new(station), - ended: OnceLock::new(), - ended_tx, - on_ended: Mutex::new(Some(on_ended)), - }); - tokio::spawn(read(channel.clone(), reader, leftover)); - tokio::spawn(write_handed_off(channel.clone(), hand_off_rx)); - channel - } - - /// Why the session ended, once it has. - pub(crate) fn end_reason(&self) -> Option { - self.ended.get().cloned() - } - - /// Resolves once the session has ended, with why. - pub(crate) async fn ended(&self) -> SessionEndReason { - let mut ended = self.ended_tx.subscribe(); - let _ = ended.wait_for(|ended| *ended).await; - self.end_reason().unwrap_or(SessionEndReason::Closed) - } - - /// How many frames of each type arrived with nothing to route them to. - pub(crate) fn unrouted_frame_counts(&self) -> HashMap { - lock(&self.unrouted).clone() - } - - /// The bounded warnings for frames this session drops and streams it - /// refuses. - pub(crate) fn drop_warnings(&self) -> &DropWarnings { - &self.drop_warnings - } - - /// Sends an already signed frame whole. Waiting for the turn to write is - /// bounded by the send timeout; the write itself runs in a task of its - /// own, so a caller that stops waiting never leaves a frame half written. - pub(crate) async fn send(self: &Arc, signed: &Value) -> Result<(), SendError> { - let bytes = frame::encode(signed).map_err(SendError::Encode)?; - let write = self - .start_write(bytes, self.send_timeout, None) - .await - .map_err(|refused| match refused { - Refused::Timeout => SendError::Timeout, - Refused::Ended(reason) => SendError::SessionEnded(reason), - })?; - match write.await { - Ok(Written::Whole) => Ok(()), - Ok(Written::Stalled) => Err(SendError::SendTimeout), - Ok(Written::Failed(e)) => Err(SendError::Write(e)), - Err(join) => Err(SendError::Write(std::io::Error::other(join))), - } - } - - /// Hands an unsigned frame to the writer of the frames the session sends - /// on its own account, without waiting. It is dropped when 64 frames - /// already wait there, or the session ended. - pub(crate) fn hand_off(&self, frame: Value) { - if let Some(frames) = lock(&self.hand_off).as_ref() { - let _ = frames.try_send(frame); - } - } - - /// Sends `spec` as a CALL signed by `identity` and waits for its reply, - /// all within `timeout`, its turn to write included. `after_written`, an - /// unsigned frame, is handed off once the CALL is written. - pub(crate) async fn call( - self: &Arc, - spec: &CallSpec, - identity: &KeyPair, - timeout: Duration, - after_written: Option, - ) -> Result { - let deadline = tokio::time::Instant::now() + timeout; - let bytes = - frame::encode(&frame::sign(frame::call(spec), identity)).map_err(CallError::Encode)?; - let (reply_tx, reply_rx) = oneshot::channel(); - lock(&self.calls).insert(spec.call_id, reply_tx); - let _waiting = Waiting { - channel: self, - call_id: spec.call_id, - }; - let write = match self.start_write(bytes, timeout, after_written).await { - Ok(write) => write, - Err(Refused::Timeout) => { - return Err(CallError::Timeout { - write_started: false, - }) - } - Err(Refused::Ended(reason)) => { - return Err(CallError::SessionEnded { - reason, - write_started: false, - }) - } - }; - match tokio::time::timeout_at(deadline, write).await { - Err(_) => { - return Err(CallError::Timeout { - write_started: true, - }) - } - Ok(Ok(Written::Whole)) => {} - Ok(Ok(Written::Stalled)) => return Err(CallError::SendTimeout), - Ok(Ok(Written::Failed(e))) => { - return Err(match self.end_reason() { - Some(reason) => CallError::SessionEnded { - reason, - write_started: true, - }, - None => CallError::Write(e), - }) - } - Ok(Err(join)) => return Err(CallError::Write(std::io::Error::other(join))), - } - match tokio::time::timeout_at(deadline, reply_rx).await { - Err(_) => Err(CallError::Timeout { - write_started: true, - }), - Ok(Ok(Ok(response))) => Ok(response), - Ok(Ok(Err(malformed))) => Err(CallError::MalformedReply(malformed)), - Ok(Err(_)) => Err(CallError::SessionEnded { - reason: self.end_reason().unwrap_or(SessionEndReason::Closed), - write_started: true, - }), - } - } - - /// The next inbound CALL signed by its caller. Once the session ended and - /// the queued calls are served, why it ended. - pub(crate) async fn next_inbound_call(&self) -> Result { - let mut calls = self.inbound_rx.lock().await; - calls - .recv() - .await - .ok_or_else(|| self.end_reason().unwrap_or(SessionEndReason::Closed)) - } - - /// Starts a subscription, sending SUBSCRIBE signed by `identity` unless - /// another subscription on this session already holds that realm and - /// topic at the station. - pub(crate) async fn subscribe( - self: &Arc, - spec: &SubscribeSpec, - identity: &KeyPair, - ) -> Result { - let _turn = self.subscription_turn.lock().await; - if let Some(reason) = self.end_reason() { - return Err(SendError::SessionEnded(reason)); - } - let (events_tx, events_rx) = mpsc::channel(EVENT_QUEUE_CAPACITY); - let overflowed = Arc::new(AtomicBool::new(false)); - let id = self.next_subscription.fetch_add(1, Ordering::Relaxed); - let first = { - let mut entries = lock(&self.subscriptions); - let first = !entries - .iter() - .any(|entry| entry.holds(&spec.realm, &spec.topic)); - entries.push(Entry { - id, - realm: spec.realm, - topic: spec.topic.clone(), - subscriber: spec.subscriber, - events: Some(events_tx), - overflowed: overflowed.clone(), - }); - first - }; - if first { - if let Err(e) = self - .send(&frame::sign(frame::subscribe(spec), identity)) - .await - { - lock(&self.subscriptions).retain(|entry| entry.id != id); - return Err(e); - } - } - Ok(Subscription { - channel: self.clone(), - id, - realm: spec.realm, - topic: spec.topic.clone(), - events: events_rx, - overflowed, - closed: false, - }) - } - - /// Ends a subscription, and sends UNSUBSCRIBE when no other subscription - /// on this session still holds its realm and topic. - async fn remove_subscription(self: &Arc, id: u64) { - let _turn = self.subscription_turn.lock().await; - let removed = { - let mut entries = lock(&self.subscriptions); - entries - .iter() - .position(|entry| entry.id == id) - .map(|index| { - let entry = entries.remove(index); - let last = !entries - .iter() - .any(|other| other.holds(&entry.realm, &entry.topic)); - (entry, last) - }) - }; - let Some((entry, true)) = removed else { - return; - }; - if self.end_reason().is_some() { - return; - } - let spec = frame::UnsubscribeSpec::new(entry.topic, entry.realm, entry.subscriber); - // Not sent in time, or the session ended; a station drops a - // connection's subscriptions together with the connection. - let _ = self - .send(&frame::sign(frame::unsubscribe(&spec), &self.identity)) - .await; - } - - /// Closes the session here: GOODBYE, as best it can within the send - /// timeout, then the stream's sending side, then the end itself. - pub(crate) async fn close(self: &Arc, goodbye: &Value) { - let _ = self.send(goodbye).await; - if let Ok(mut writer) = tokio::time::timeout(self.send_timeout, self.writer.lock()).await { - let _ = tokio::time::timeout(self.send_timeout, writer.shutdown()).await; - } - self.end(SessionEndReason::Closed, true); - } - - /// Ends the session with `reason`, once: logs it, fails every waiting - /// call, ends every subscription and the inbound call queue, stops the - /// reader and the hand-off writer, and runs `on_ended`. - pub(crate) fn end(&self, reason: SessionEndReason, closed_here: bool) { - if self.ended.set(reason.clone()).is_err() { - return; - } - // Every end is reported once, so why a session went away can be found - // afterwards: a warning when the station or the connection ended it, - // information when it was closed here. - let report = format!( - "macula: session {} to station {} ended: {reason}", - hex(&self.identity.node_id()), - hex(&self.station) - ); - if closed_here { - log::info!("{report}"); - } else { - log::warn!("{report}"); - } - self.ended_tx.send_replace(true); - lock(&self.calls).clear(); - for entry in lock(&self.subscriptions).iter_mut() { - entry.events = None; - } - lock(&self.inbound_tx).take(); - lock(&self.hand_off).take(); - if let Some(on_ended) = lock(&self.on_ended).take() { - on_ended(&reason, closed_here); - } - } - - /// Waits up to `wait` for the turn to write, then writes `bytes` in a task - /// of its own that gives the turn back when done, bounded by the send - /// timeout. A write that stalls past it ends the session before the turn - /// is given back, so nothing is written after a half-written frame. - async fn start_write( - self: &Arc, - bytes: Vec, - wait: Duration, - after_written: Option, - ) -> Result, Refused> { - if let Some(reason) = self.end_reason() { - return Err(Refused::Ended(reason)); - } - let mut ended = self.ended_tx.subscribe(); - let turn = tokio::select! { - biased; - _ = ended.wait_for(|ended| *ended) => { - return Err(Refused::Ended(self.end_reason().unwrap_or(SessionEndReason::Closed))); - } - turn = tokio::time::timeout(wait, self.writer.clone().lock_owned()) => { - turn.map_err(|_| Refused::Timeout)? - } - }; - if let Some(reason) = self.end_reason() { - return Err(Refused::Ended(reason)); - } - let channel = self.clone(); - Ok(tokio::spawn(async move { - let mut writer = turn; - let written = tokio::time::timeout(channel.send_timeout, async { - writer.write_all(&bytes).await?; - writer.flush().await - }) - .await; - let outcome = match written { - Ok(Ok(())) => Written::Whole, - Ok(Err(e)) => Written::Failed(e), - Err(_) => { - channel.end(SessionEndReason::SendTimeout, false); - Written::Stalled - } - }; - drop(writer); - if let (Written::Whole, Some(frame)) = (&outcome, after_written) { - channel.hand_off(frame); - } - outcome - })) - } - - // Routes one frame, returning why when the frame ends the session. - fn route(&self, frame: Value) -> Option { - let frame_type = text_field(&frame, "frame_type").unwrap_or_else(|| "unknown".to_string()); - match frame_type.as_str() { - "result" | "error" => { - self.complete_call(&frame_type, &frame); - None - } - "event" => { - self.deliver_event(&frame); - None - } - "call" => { - self.queue_inbound_call(&frame); - None - } - "goodbye" => Some(SessionEndReason::Goodbye { - reason: text_field(&frame, "reason") - .unwrap_or_else(|| "no reason given".to_string()), - detail: text_field(&frame, "detail"), - }), - "hello" | "connect" => Some(SessionEndReason::ProtocolViolation { frame_type }), - _ => { - self.drop_unrouted(&frame_type); - None - } - } - } - - fn complete_call(&self, frame_type: &str, frame: &Value) { - let call_id = frame::frame_call_id(frame); - let waiting = call_id.and_then(|call_id| lock(&self.calls).remove(&call_id)); - match (waiting, call_id) { - (Some(reply), _) => { - let _ = reply.send(frame::parse_call_response(frame)); - } - (None, Some(call_id)) => { - self.drop_reply(frame_type, Reason::UnknownCallId, Subject::CallId(call_id)) - } - (None, None) => self.drop_reply(frame_type, Reason::Malformed, Subject::Nothing), - } - } - - /// Counts a RESULT or ERROR no call waits for, with a bounded warning. - fn drop_reply(&self, frame_type: &str, reason: Reason, subject: Subject) { - self.count_unrouted(frame_type); - self.drop_warnings - .record(Kind::DroppedReply, reason, subject); - } - - fn deliver_event(&self, frame: &Value) { - let Ok(event) = frame::parse_event(frame) else { - self.drop_unrouted("event"); - return; - }; - let mut matched = false; - for entry in lock(&self.subscriptions).iter_mut() { - let Some(events) = entry.events.as_ref() else { - continue; - }; - if entry.realm != event.realm || !topic_matches(&entry.topic, &event.topic) { - continue; - } - matched = true; - if let Err(mpsc::error::TrySendError::Full(_)) = events.try_send(event.clone()) { - entry.overflowed.store(true, Ordering::Release); - entry.events = None; - } - } - if !matched { - self.drop_unrouted("event"); - } - } - - fn queue_inbound_call(&self, frame: &Value) { - // A CALL that isn't signed by the caller it names gets no reply, and - // nothing else looks at it first, as in macula_station_link.erl's - // on_inbound_call/3. - if let Err(reason) = drop_warning::signed_caller(frame) { - self.drop_call(reason, frame); - return; - } - let Ok(call) = frame::parse_call(frame) else { - self.drop_call(Reason::Malformed, frame); - return; - }; - let refused = match lock(&self.inbound_tx).as_ref() { - Some(calls) => match calls.try_send(call) { - Err(mpsc::error::TrySendError::Full(call)) => Some(call.call_id), - _ => None, - }, - None => None, - }; - // A CALL that doesn't fit gets temporary_relay_failure, as a handler - // crash does: the handler never ran, so the caller may try again - // instead of waiting out its deadline. Serving carries on with the - // calls queued, and when the hand-off is full too, the refusal is - // dropped and the caller's deadline covers it. - if let Some(call_id) = refused { - self.hand_off(frame::call_error(&frame::CallErrorSpec::new( - call_id, - bolt4::Code::TemporaryRelayFailure, - self.identity.node_id(), - ))); - } - } - - /// Counts a dropped inbound CALL, with a bounded warning. - fn drop_call(&self, reason: Reason, frame: &Value) { - self.count_unrouted("call"); - self.drop_warnings - .record(Kind::DroppedCall, reason, drop_warning::procedure_of(frame)); - } - - fn count_unrouted(&self, frame_type: &str) { - *lock(&self.unrouted) - .entry(frame_type.to_string()) - .or_default() += 1; - } - - /// Counts a frame nothing routes, with at most one warning line per frame - /// type a minute. - fn drop_unrouted(&self, frame_type: &str) { - self.count_unrouted(frame_type); - let mut reported = lock(&self.reported); - let (dropped, last) = reported.entry(frame_type.to_string()).or_insert((0, None)); - *dropped += 1; - let now = std::time::Instant::now(); - if last.is_none_or(|last| now.duration_since(last) >= LOG_INTERVAL) { - log::warn!( - "macula: dropped {dropped} unrouted {frame_type} frame(s) in the last minute (station {})", - hex(&self.station) - ); - *dropped = 0; - *last = Some(now); - } - } -} - -/// Removes a call's reply slot however the call ends, so a late reply is -/// counted as unrouted. -struct Waiting<'a> { - channel: &'a Channel, - call_id: [u8; 16], -} - -impl Drop for Waiting<'_> { - fn drop(&mut self) { - lock(&self.channel.calls).remove(&self.call_id); - } -} - -async fn read(channel: Arc, mut reader: BoxRead, mut buf: Vec) { - let mut chunk = vec![0u8; READ_CHUNK]; - let mut ended = channel.ended_tx.subscribe(); - loop { - loop { - match frame::decode(&buf) { - Ok(Decoded::Frame(value, consumed)) => { - buf.drain(..consumed); - if let Some(reason) = channel.route(value) { - channel.end(reason, false); - return; - } - } - Ok(Decoded::More(_)) => break, - Err(e) => { - // Nothing after a frame that can't be decoded can be read - // in step. - channel.end(SessionEndReason::Malformed(e.to_string()), false); - return; - } - } - } - let read = tokio::select! { - _ = ended.wait_for(|ended| *ended) => return, - read = reader.read(&mut chunk) => read, - }; - match read { - Ok(0) => { - channel.end( - SessionEndReason::StreamFailed( - "the station closed the control stream".to_string(), - ), - false, - ); - return; - } - Ok(n) => buf.extend_from_slice(&chunk[..n]), - Err(e) => { - channel.end( - SessionEndReason::StreamFailed(format!("reading: {e}")), - false, - ); - return; - } - } - } -} - -// Sends the frames handed off, one at a time, until the session ends. -async fn write_handed_off(channel: Arc, mut frames: mpsc::Receiver) { - while let Some(frame) = frames.recv().await { - // Not sent in time, or the session ended. Nothing waits on these - // frames: a caller's own deadline covers a missing reply, and a fact - // is best effort. - let _ = channel.send(&frame::sign(frame, &channel.identity)).await; - } -} - -/// One subscription on a [`Session`](crate::connection::Session): every -/// EVENT whose realm is this subscription's and whose topic matches its -/// pattern is copied into a queue of its own of 256 events. Several -/// subscriptions on one session each get their own copy. -/// -/// A subscription that falls more than 256 events behind gets its queued -/// events, then [`RecvEventError::Overflow`], and nothing more, while the -/// session and its other subscriptions carry on. It keeps the station -/// subscribed until it is closed. Close it, or drop it, to stop: the session -/// sends UNSUBSCRIBE once no other subscription on it holds that realm and -/// topic. -pub struct Subscription { - channel: Arc, - id: u64, - realm: [u8; 32], - topic: String, - events: mpsc::Receiver, - overflowed: Arc, - closed: bool, -} - -impl Subscription { - pub fn realm(&self) -> [u8; 32] { - self.realm - } - - /// The topic pattern this subscription matches. - pub fn topic(&self) -> &str { - &self.topic - } - - /// Whether this subscription fell behind and receives nothing more. - pub fn is_overflowed(&self) -> bool { - self.overflowed.load(Ordering::Acquire) - } - - /// Waits up to `timeout` for the next event. - pub async fn recv_event(&mut self, timeout: Duration) -> Result { - match tokio::time::timeout(timeout, self.events.recv()).await { - Err(_) => Err(RecvEventError::Timeout), - Ok(Some(event)) => Ok(event), - Ok(None) if self.is_overflowed() => Err(RecvEventError::Overflow), - Ok(None) => Err(RecvEventError::SessionEnded( - self.channel - .end_reason() - .unwrap_or(SessionEndReason::Closed), - )), - } - } - - /// Ends this subscription, and sends UNSUBSCRIBE when no other - /// subscription on the session holds its realm and topic. - pub async fn close(mut self) { - self.closed = true; - self.channel.remove_subscription(self.id).await; - } -} - -impl Drop for Subscription { - fn drop(&mut self) { - if self.closed { - return; - } - let (channel, id) = (self.channel.clone(), self.id); - match tokio::runtime::Handle::try_current() { - Ok(runtime) => { - runtime.spawn(async move { channel.remove_subscription(id).await }); - } - // Outside a runtime nothing can be sent; the station drops the - // subscription with the connection. - Err(_) => lock(&channel.subscriptions).retain(|entry| entry.id != id), - } - } -} - -pub(crate) mod drop_warning; - -#[cfg(test)] -pub(crate) mod fake_station; - -#[cfg(test)] -mod tests { - //! The session reader, over in-memory pipes. The names match the Go and - //! .NET tests. - use super::fake_station::*; - use super::*; - - #[tokio::test] - async fn concurrent_calls_on_one_session_each_get_their_own_reply() { - let (channel, mut station, _ended) = connect(); - let procedures: Vec = (0..10).map(|i| format!("app/echo_{i}")).collect(); - - let calls: Vec<_> = procedures - .iter() - .map(|procedure| spawn_call(&channel, call(procedure), WAIT)) - .collect(); - let mut sent = Vec::new(); - for _ in &procedures { - sent.push(station.next("call").await); - } - for frame in sent.iter().rev() { - let procedure = text_field(frame, "procedure").expect("a CALL names its procedure"); - station.reply(frame, &procedure).await; - } - - for (procedure, call) in procedures.iter().zip(calls) { - assert_eq!(reply_text(call.await.unwrap().unwrap()), *procedure); - } - } - - #[tokio::test] - async fn an_event_arriving_during_a_call_reaches_its_subscriber() { - let (channel, mut station, _ended) = connect(); - let mut subscription = channel - .subscribe(&subscribe("app/orders/placed"), &KeyPair::generate()) - .await - .unwrap(); - - let pending = spawn_call(&channel, call("app/echo"), WAIT); - let sent = station.next("call").await; - station.send_event("app/orders/placed", "order 1").await; - station.reply(&sent, "echoed").await; - - assert_eq!(reply_text(pending.await.unwrap().unwrap()), "echoed"); - assert_eq!( - event_text(subscription.recv_event(WAIT).await.unwrap()), - "order 1" - ); - } - - #[tokio::test] - async fn serving_a_call_while_calling_on_the_same_session() { - let (channel, mut station, _ended) = connect(); - - let served = { - let channel = channel.clone(); - tokio::spawn(async move { channel.next_inbound_call().await }) - }; - let pending = spawn_call(&channel, call("app/echo"), WAIT); - let sent = station.next("call").await; - station.send_inbound_call("app/greet", Signer::Caller).await; - station.reply(&sent, "echoed").await; - - assert_eq!(served.await.unwrap().unwrap().procedure, "app/greet"); - assert_eq!(reply_text(pending.await.unwrap().unwrap()), "echoed"); - } - - #[tokio::test] - async fn a_queued_call_past_its_deadline_is_still_served() { - let (channel, mut station, _ended) = connect(); - - station - .send_inbound_call_due("app/echo", Signer::Caller, now_ms() - 1_000) - .await; - - let call = tokio::time::timeout(WAIT, channel.next_inbound_call()) - .await - .unwrap() - .unwrap(); - let echo: crate::connection::CallHandler = Arc::new(|payload: Value| { - Box::pin(async move { Ok(payload) }) - as crate::connection::BoxFuture<'static, Result> - }); - let lookup = - move |_: &[u8; 32], procedure: &str| (procedure == "app/echo").then(|| echo.clone()); - let reply = crate::connection::build_call_reply( - call, - &lookup, - &|_: &[u8; 32], _: &str| crate::ucan::Policy::open(), - &KeyPair::generate(), - None, - ) - .await; - assert!(matches!( - frame::parse_call_response(&reply), - Ok(CallResponse::Result { .. }) - )); - } - - #[tokio::test] - async fn two_subscribers_with_different_topics_each_get_only_their_events() { - let (channel, mut station, _ended) = connect(); - let id = KeyPair::generate(); - let mut orders = channel - .subscribe(&subscribe("app/orders"), &id) - .await - .unwrap(); - let mut invoices = channel - .subscribe(&subscribe("app/invoices"), &id) - .await - .unwrap(); - - station.send_event("app/orders", "order 1").await; - station.send_event("app/invoices", "invoice 1").await; - - assert_eq!( - event_text(orders.recv_event(WAIT).await.unwrap()), - "order 1" - ); - assert_eq!( - event_text(invoices.recv_event(WAIT).await.unwrap()), - "invoice 1" - ); - let short = Duration::from_millis(200); - assert!(matches!( - orders.recv_event(short).await, - Err(RecvEventError::Timeout) - )); - assert!(matches!( - invoices.recv_event(short).await, - Err(RecvEventError::Timeout) - )); - } - - #[tokio::test] - async fn a_wildcard_subscription_matches_exactly_one_segment() { - let (channel, mut station, _ended) = connect(); - let mut placed = channel - .subscribe(&subscribe("app/*/placed"), &KeyPair::generate()) - .await - .unwrap(); - - station - .send_event("app/orders/eu/placed", "two segments") - .await; - station.send_event("app/placed", "no segment").await; - station.send_event("app/orders/placed", "one segment").await; - - assert_eq!( - event_text(placed.recv_event(WAIT).await.unwrap()), - "one segment" - ); - assert!(matches!( - placed.recv_event(Duration::from_millis(200)).await, - Err(RecvEventError::Timeout) - )); - } - - #[tokio::test] - async fn closing_the_last_subscription_for_a_topic_unsubscribes() { - let (channel, mut station, _ended) = connect(); - let id = KeyPair::generate(); - let first = channel - .subscribe(&subscribe("app/orders"), &id) - .await - .unwrap(); - let second = channel - .subscribe(&subscribe("app/orders"), &id) - .await - .unwrap(); - assert_eq!(frame_type(&station.next_frame().await), "subscribe"); - - first.close().await; - // A call right after shows what the session sent in between: nothing. - let pending = spawn_call(&channel, call("app/echo"), WAIT); - let sent = station.next_frame().await; - assert_eq!(frame_type(&sent), "call"); - station.reply(&sent, "echoed").await; - pending.await.unwrap().unwrap(); - - second.close().await; - assert_eq!(frame_type(&station.next_frame().await), "unsubscribe"); - } - - #[tokio::test] - async fn an_overflowed_subscription_keeps_the_station_subscribed_until_it_is_closed() { - let (channel, mut station, _ended) = connect(); - let behind = channel - .subscribe(&subscribe("app/ticks"), &KeyPair::generate()) - .await - .unwrap(); - assert_eq!(frame_type(&station.next_frame().await), "subscribe"); - - // The call's reply comes after every event, so by the time it - // arrives the reader has routed all of them. - let pending = spawn_call(&channel, call("app/echo"), WAIT); - let sent = station.next_frame().await; - for i in 0..=EVENT_QUEUE_CAPACITY { - station.send_event("app/ticks", &format!("tick {i}")).await; - } - station.reply(&sent, "echoed").await; - pending.await.unwrap().unwrap(); - assert!(behind.is_overflowed()); - - // A call right after shows what the session sent since: nothing. - let probe = spawn_call(&channel, call("app/echo"), WAIT); - let next = station.next_frame().await; - assert_eq!(frame_type(&next), "call"); - station.reply(&next, "echoed").await; - probe.await.unwrap().unwrap(); - - behind.close().await; - assert_eq!(frame_type(&station.next_frame().await), "unsubscribe"); - } - - #[tokio::test] - async fn a_stalled_event_consumer_does_not_stall_call_replies() { - let (channel, mut station, _ended) = connect(); - let _stalled = channel - .subscribe(&subscribe("app/ticks"), &KeyPair::generate()) - .await - .unwrap(); - - let pending = spawn_call(&channel, call("app/echo"), WAIT); - let sent = station.next("call").await; - for i in 0..EVENT_QUEUE_CAPACITY + 44 { - station.send_event("app/ticks", &format!("tick {i}")).await; - } - station.reply(&sent, "echoed").await; - - assert_eq!(reply_text(pending.await.unwrap().unwrap()), "echoed"); - } - - #[tokio::test] - async fn an_overflowing_event_consumer_ends_with_an_overflow_error_and_the_session_stays_up() { - let (channel, mut station, _ended) = connect(); - let id = KeyPair::generate(); - let mut behind = channel - .subscribe(&subscribe("app/ticks"), &id) - .await - .unwrap(); - - let pending = spawn_call(&channel, call("app/echo"), WAIT); - let sent = station.next("call").await; - for i in 0..=EVENT_QUEUE_CAPACITY { - station.send_event("app/ticks", &format!("tick {i}")).await; - } - station.reply(&sent, "echoed").await; - pending.await.unwrap().unwrap(); - - for i in 0..EVENT_QUEUE_CAPACITY { - assert_eq!( - event_text(behind.recv_event(WAIT).await.unwrap()), - format!("tick {i}") - ); - } - assert!(matches!( - behind.recv_event(WAIT).await, - Err(RecvEventError::Overflow) - )); - - let mut fresh = channel - .subscribe(&subscribe("app/ticks"), &id) - .await - .unwrap(); - station.send_event("app/ticks", "after the overflow").await; - assert_eq!( - event_text(fresh.recv_event(WAIT).await.unwrap()), - "after the overflow" - ); - } - - #[tokio::test] - async fn an_overflowing_call_queue_answers_the_extra_call_and_keeps_serving() { - let (channel, mut station, _ended) = connect(); - - let mut inbound = Vec::new(); - for i in 0..=CALL_QUEUE_CAPACITY { - inbound.push( - station - .send_inbound_call(&format!("app/job_{i}"), Signer::Caller) - .await, - ); - } - - let refusal = station.next("error").await; - assert_eq!(frame::frame_call_id(&refusal), inbound.last().copied()); - match frame::parse_call_response(&refusal) { - Ok(CallResponse::Error { name, .. }) => assert_eq!(name, "temporary_relay_failure"), - other => panic!("expected an ERROR, got {other:?}"), - } - - // Serving carries on: the queued calls, then the next one to arrive. - for i in 0..CALL_QUEUE_CAPACITY { - let served = tokio::time::timeout(WAIT, channel.next_inbound_call()) - .await - .unwrap() - .unwrap(); - assert_eq!(served.procedure, format!("app/job_{i}")); - } - station - .send_inbound_call("app/after_the_overflow", Signer::Caller) - .await; - let served = tokio::time::timeout(WAIT, channel.next_inbound_call()) - .await - .unwrap() - .unwrap(); - assert_eq!(served.procedure, "app/after_the_overflow"); - } - - #[tokio::test] - async fn a_call_that_cannot_get_the_write_lock_in_time_times_out_and_the_session_stays_up() { - let (channel, mut station, mut ended) = connect(); - station.stall_session_writes(); - let publishing = { - let channel = channel.clone(); - tokio::spawn(async move { channel.send(&publish("app/ticks")).await }) - }; - turn_taken(&channel).await; - - let timed_out = channel - .call( - &call("app/echo"), - &KeyPair::generate(), - Duration::from_millis(100), - None, - ) - .await; - assert!( - matches!( - timed_out, - Err(CallError::Timeout { - write_started: false - }) - ), - "{timed_out:?}" - ); - assert!(ended.try_recv().is_err(), "the session stays up"); - - station.resume_session_writes(); - publishing.await.unwrap().unwrap(); - assert_eq!(frame_type(&station.next_frame().await), "publish"); - let next = call("app/echo"); - let call_id = next.call_id; - let pending = spawn_call(&channel, next, WAIT); - let sent = station.next_frame().await; - assert_eq!(frame::frame_call_id(&sent), Some(call_id)); - station.reply(&sent, "echoed").await; - assert_eq!(reply_text(pending.await.unwrap().unwrap()), "echoed"); - } - - #[tokio::test] - async fn a_write_stalled_past_the_send_timeout_ends_the_session() { - let (channel, station, ended) = connect_with(Duration::from_millis(200)); - station.stall_session_writes(); - - let published = tokio::time::timeout(WAIT, channel.send(&publish("app/ticks"))) - .await - .unwrap(); - assert!( - matches!(published, Err(SendError::SendTimeout)), - "{published:?}" - ); - let (reason, _) = tokio::time::timeout(WAIT, ended).await.unwrap().unwrap(); - assert_eq!(reason, SessionEndReason::SendTimeout); - let called = channel - .call(&call("app/echo"), &KeyPair::generate(), WAIT, None) - .await; - assert!( - matches!( - called, - Err(CallError::SessionEnded { - reason: SessionEndReason::SendTimeout, - write_started: false - }) - ), - "{called:?}" - ); - } - - #[tokio::test] - async fn the_reader_keeps_delivering_while_a_write_is_stalled() { - let (channel, mut station, _ended) = connect(); - let mut ticks = channel - .subscribe(&subscribe("app/ticks"), &KeyPair::generate()) - .await - .unwrap(); - station.next("subscribe").await; - station.stall_session_writes(); - - // The extra calls' refusals can't go out while writes are stalled. - for i in 0..CALL_QUEUE_CAPACITY + 4 { - station - .send_inbound_call(&format!("app/job_{i}"), Signer::Caller) - .await; - } - station.send_event("app/ticks", "tick 1").await; - - assert_eq!(event_text(ticks.recv_event(WAIT).await.unwrap()), "tick 1"); - station.resume_session_writes(); - } - - #[tokio::test] - async fn a_call_timing_out_while_its_frame_is_being_written_reports_it_may_have_been_sent() { - let (channel, station, mut ended) = connect(); - station.stall_session_writes(); - - let called = channel - .call( - &call("app/echo"), - &KeyPair::generate(), - Duration::from_millis(100), - None, - ) - .await; - - assert!( - matches!( - called, - Err(CallError::Timeout { - write_started: true - }) - ), - "{called:?}" - ); - assert!(ended.try_recv().is_err(), "the session stays up"); - station.resume_session_writes(); - } - - #[tokio::test] - async fn a_frame_that_cannot_be_decoded_ends_the_session() { - let (channel, mut station, ended) = connect(); - let pending = spawn_call(&channel, call("app/echo"), Duration::from_secs(10)); - station.next("call").await; - - // A one-byte frame whose CBOR initial byte uses a reserved value. - station.send_raw(&[0, 0, 0, 1, 0x1C]).await; - - let (reason, closed_here) = tokio::time::timeout(WAIT, ended).await.unwrap().unwrap(); - assert!( - matches!(reason, SessionEndReason::Malformed(_)), - "{reason:?}" - ); - assert!(!closed_here); - let called = pending.await.unwrap(); - assert!( - matches!( - called, - Err(CallError::SessionEnded { - write_started: true, - .. - }) - ), - "{called:?}" - ); - } - - #[tokio::test] - async fn a_call_on_a_session_that_has_ended_reports_it_was_not_sent() { - let (channel, station, ended) = connect(); - drop(station); - tokio::time::timeout(WAIT, ended).await.unwrap().unwrap(); - - let called = channel - .call(&call("app/echo"), &KeyPair::generate(), WAIT, None) - .await; - - assert!( - matches!( - called, - Err(CallError::SessionEnded { - write_started: false, - .. - }) - ), - "{called:?}" - ); - } - - #[tokio::test] - async fn a_call_waiting_for_the_write_lock_when_the_session_ends_reports_it_was_not_sent() { - let (channel, mut station, _ended) = connect(); - station.stall_session_writes(); - let _publishing = { - let channel = channel.clone(); - tokio::spawn(async move { channel.send(&publish("app/ticks")).await }) - }; - turn_taken(&channel).await; - let pending = spawn_call(&channel, call("app/echo"), Duration::from_secs(30)); - - station.send(&frame::goodbye("maintenance", None)).await; - - // Well before the call's own 30 second deadline. - let called = tokio::time::timeout(WAIT, pending).await.unwrap().unwrap(); - assert!( - matches!( - called, - Err(CallError::SessionEnded { - write_started: false, - .. - }) - ), - "{called:?}" - ); - station.resume_session_writes(); - } - - #[tokio::test] - async fn a_goodbye_from_the_station_fails_pending_calls_and_ends_the_session() { - let (channel, mut station, ended) = connect(); - let pending = spawn_call(&channel, call("app/echo"), Duration::from_secs(10)); - station.next("call").await; - - station.send(&frame::goodbye("maintenance", None)).await; - - match pending.await.unwrap() { - Err(CallError::SessionEnded { - reason: SessionEndReason::Goodbye { reason, .. }, - write_started: true, - }) => assert_eq!(reason, "maintenance"), - other => panic!("expected the goodbye to end the call, got {other:?}"), - } - let (reason, _) = tokio::time::timeout(WAIT, ended).await.unwrap().unwrap(); - assert!( - matches!(reason, SessionEndReason::Goodbye { ref reason, .. } if reason == "maintenance"), - "{reason:?}" - ); - } - - #[tokio::test] - async fn a_hello_after_the_handshake_ends_the_session() { - let (channel, mut station, ended) = connect(); - let pending = spawn_call(&channel, call("app/echo"), Duration::from_secs(10)); - station.next("call").await; - - station - .send(&Value::Map(vec![( - Value::text("frame_type"), - Value::text("hello"), - )])) - .await; - - assert!( - matches!( - pending.await.unwrap(), - Err(CallError::SessionEnded { - reason: SessionEndReason::ProtocolViolation { .. }, - .. - }) - ), - "a HELLO after the handshake ends the call" - ); - let (reason, _) = tokio::time::timeout(WAIT, ended).await.unwrap().unwrap(); - assert!( - matches!(reason, SessionEndReason::ProtocolViolation { .. }), - "{reason:?}" - ); - } - - #[tokio::test] - async fn a_session_end_is_logged_once_with_its_reason() { - capture_logs(); - - let (ended_by_station, mut station, ended) = connect(); - let station_id = station.identity.node_id(); - station.send(&frame::goodbye("maintenance", None)).await; - tokio::time::timeout(WAIT, ended).await.unwrap().unwrap(); - ended_by_station.end(SessionEndReason::Closed, true); - - let (ended_here, _other_station, _) = connect(); - ended_here.end(SessionEndReason::Closed, true); - - let by_station = logged_about(&ended_by_station.identity.node_id()); - assert_eq!(by_station.len(), 1, "{by_station:?}"); - assert_eq!(by_station[0].0, log::Level::Warn); - assert!(by_station[0].1.contains("maintenance"), "{by_station:?}"); - assert!( - by_station[0].1.contains(&hex(&station_id)), - "{by_station:?}" - ); - let by_us = logged_about(&ended_here.identity.node_id()); - assert_eq!(by_us.len(), 1, "{by_us:?}"); - assert_eq!(by_us[0].0, log::Level::Info); - } - - #[tokio::test] - async fn an_unrouted_frame_is_counted_by_type() { - let (channel, mut station, _ended) = connect(); - - let pending = spawn_call(&channel, call("app/echo"), WAIT); - let sent = station.next("call").await; - let advertise = Value::Map(vec![(Value::text("frame_type"), Value::text("advertise"))]); - station.send(&advertise).await; - station.send(&advertise).await; - let stray = frame::result(&frame::ResultSpec::new( - rand::random(), - Value::Null, - station.identity.node_id(), - )); - station.send(&stray).await; - station.reply(&sent, "echoed").await; - pending.await.unwrap().unwrap(); - - let counts = channel.unrouted_frame_counts(); - assert_eq!(counts.get("advertise"), Some(&2)); - assert_eq!(counts.get("result"), Some(&1)); - } - - struct OpenSession; - - impl crate::open_sessions::Live for OpenSession { - fn is_live(&self) -> bool { - true - } - } - - #[tokio::test] - async fn a_session_whose_connection_ends_is_no_longer_found_for_reuse() { - let (identity, station_id): ([u8; 32], [u8; 32]) = (rand::random(), rand::random()); - let open = Arc::new(crate::open_sessions::OpenSessions::::default()); - let session = Arc::new(OpenSession); - open.register(identity, station_id, &session); - let (ended_tx, ended_rx) = oneshot::channel(); - let (registry, registered) = (open.clone(), session.clone()); - let (_channel, station) = connect_ending( - SEND_TIMEOUT, - Box::new(move |reason, _| { - registry.unregister(identity, station_id, ®istered); - let _ = ended_tx.send(reason.clone()); - }), - ); - - drop(station); - - let reason = tokio::time::timeout(WAIT, ended_rx).await.unwrap().unwrap(); - assert!( - matches!(reason, SessionEndReason::StreamFailed(_)), - "{reason:?}" - ); - assert!(open.find(identity, station_id).is_none()); - } - - // Guards moved here from the serve tests with the signature check itself: - // a CALL that isn't signed by the caller it names never reaches serving - // and gets no reply, as in macula_station_link.erl's on_inbound_call/3. - - #[tokio::test] - async fn an_inbound_call_not_signed_by_its_caller_is_dropped() { - let (channel, mut station, _ended) = connect(); - - station - .send_inbound_call("app/forged", Signer::Other(Box::new(KeyPair::generate()))) - .await; - station - .send_inbound_call("app/genuine", Signer::Caller) - .await; - - let served = tokio::time::timeout(WAIT, channel.next_inbound_call()) - .await - .unwrap() - .unwrap(); - assert_eq!(served.procedure, "app/genuine"); - assert_eq!(channel.unrouted_frame_counts().get("call"), Some(&1)); - } - - #[tokio::test] - async fn an_unsigned_inbound_call_is_dropped() { - let (channel, mut station, _ended) = connect(); - - station - .send_inbound_call("app/unsigned", Signer::Nobody) - .await; - station - .send_inbound_call("app/genuine", Signer::Caller) - .await; - - let served = tokio::time::timeout(WAIT, channel.next_inbound_call()) - .await - .unwrap() - .unwrap(); - assert_eq!(served.procedure, "app/genuine"); - assert_eq!(channel.unrouted_frame_counts().get("call"), Some(&1)); - } - - #[test] - fn a_topic_pattern_matches_by_whole_segments() { - assert!(topic_matches("app/orders", "app/orders")); - assert!(topic_matches("app/*/placed", "app/orders/placed")); - assert!(!topic_matches("app/*/placed", "app/orders/eu/placed")); - assert!(!topic_matches("app/*", "app")); - assert!(!topic_matches("app/orders", "app/order")); - } -} diff --git a/src/control_channel/drop_warning.rs b/src/control_channel/drop_warning.rs deleted file mode 100644 index 2b65fc8..0000000 --- a/src/control_channel/drop_warning.rs +++ /dev/null @@ -1,332 +0,0 @@ -//! The warnings a session logs when it drops an inbound frame or refuses a -//! stream, bounded per kind: the first drop in an interval is logged at once, -//! and the rest in that interval are counted into one closing line when it -//! ends, so a flood of bad frames can't flood the log. The kinds, reasons and -//! fields are the same in every Macula stack. - -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; -use std::time::Duration; - -use crate::cbor::Value; -use crate::frame; - -/// The interval a session starts with. -pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(60); - -/// How many bytes of a procedure a warning line carries. -const PROCEDURE_LIMIT: usize = 256; - -/// What was dropped or refused. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum Kind { - RefusedStreamOpen, - DroppedCall, - DroppedReply, -} - -impl Kind { - fn name(self) -> &'static str { - match self { - Kind::RefusedStreamOpen => "refused_stream_open", - Kind::DroppedCall => "dropped_call", - Kind::DroppedReply => "dropped_reply", - } - } -} - -/// Why it was dropped or refused. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum Reason { - /// A well-formed signature that doesn't verify against the caller the - /// frame names. - InvalidSignature, - /// A signature that's missing or isn't 64 bytes, or a caller that isn't a - /// 32-byte key. - Unsigned, - /// A frame that doesn't parse. - Malformed, - /// A dedicated stream whose first frame is of another type. - NotAStreamOpen, - /// A RESULT or ERROR for no pending call. - UnknownCallId, -} - -impl Reason { - fn name(self) -> &'static str { - match self { - Reason::InvalidSignature => "invalid_signature", - Reason::Unsigned => "unsigned", - Reason::Malformed => "malformed", - Reason::NotAStreamOpen => "not_a_stream_open", - Reason::UnknownCallId => "unknown_call_id", - } - } -} - -/// What a warning line names besides its kind and reason. -#[derive(Debug)] -pub(crate) enum Subject { - Procedure(String), - CallId([u8; 16]), - Nothing, -} - -impl Subject { - fn field(&self) -> String { - match self { - Subject::Procedure(procedure) => { - format!(" procedure={}", printable(truncated(procedure))) - } - Subject::CallId(call_id) => format!(" call_id={}", super::hex(&call_id[..4])), - Subject::Nothing => String::new(), - } - } -} - -/// The caller `frame` names, when the frame is signed by it: the check macula -/// runs on an inbound CALL or STREAM_OPEN before anything else looks at it. -pub(crate) fn signed_caller(frame: &Value) -> Result<[u8; 32], Reason> { - let caller = match frame.get("caller") { - Some(Value::Bytes(bytes)) => { - <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| Reason::Unsigned)? - } - _ => return Err(Reason::Unsigned), - }; - match frame::verify(frame, &caller) { - Ok(()) => Ok(caller), - Err(frame::VerifyError::MissingSignature | frame::VerifyError::BadSignature) => { - Err(Reason::Unsigned) - } - Err(frame::VerifyError::SignatureInvalid) => Err(Reason::InvalidSignature), - } -} - -/// The procedure `frame` names, as a warning line carries it. -pub(crate) fn procedure_of(frame: &Value) -> Subject { - match frame.get("procedure") { - Some(Value::Bytes(bytes)) => { - Subject::Procedure(String::from_utf8_lossy(bytes).into_owned()) - } - Some(Value::Text(text)) => Subject::Procedure(text.clone()), - _ => Subject::Nothing, - } -} - -/// A session's drop warnings, one limiter per kind. -pub(crate) struct DropWarnings { - station: [u8; 32], - interval: Mutex, - refused_stream_open: Limiter, - dropped_call: Limiter, - dropped_reply: Limiter, -} - -#[derive(Default)] -struct Limiter(Arc>); - -#[derive(Default)] -struct Window { - /// An interval is running: its first drop was logged at once. - open: bool, - /// The drops after that first one, and the latest one's reason and - /// subject field. - later: u64, - latest: Option<(Reason, String)>, -} - -impl DropWarnings { - pub(crate) fn new(station: [u8; 32]) -> Self { - Self { - station, - interval: Mutex::new(DEFAULT_INTERVAL), - refused_stream_open: Limiter::default(), - dropped_call: Limiter::default(), - dropped_reply: Limiter::default(), - } - } - - pub(crate) fn interval(&self) -> Duration { - *lock(&self.interval) - } - - pub(crate) fn set_interval(&self, interval: Duration) { - *lock(&self.interval) = interval; - } - - /// Records one drop of `kind`. The first in an interval is logged at once; - /// later ones are counted and reported in one line when the interval ends, - /// and not at all when none came. - pub(crate) fn record(&self, kind: Kind, reason: Reason, subject: Subject) { - let limiter = match kind { - Kind::RefusedStreamOpen => &self.refused_stream_open, - Kind::DroppedCall => &self.dropped_call, - Kind::DroppedReply => &self.dropped_reply, - }; - let field = subject.field(); - { - let mut window = lock(&limiter.0); - if window.open { - window.later += 1; - window.latest = Some((reason, field)); - return; - } - window.open = true; - } - log_line(self.station, kind, 1, reason, &field); - let (window, interval, station) = (limiter.0.clone(), self.interval(), self.station); - let close = async move { - tokio::time::sleep(interval).await; - let (later, latest) = { - let mut window = lock(&window); - window.open = false; - (std::mem::take(&mut window.later), window.latest.take()) - }; - if let Some((reason, field)) = latest.filter(|_| later > 0) { - log_line(station, kind, later, reason, &field); - } - }; - match tokio::runtime::Handle::try_current() { - Ok(runtime) => { - runtime.spawn(close); - } - // With no runtime to end the interval, every drop gets its own line. - Err(_) => lock(&limiter.0).open = false, - } - } -} - -fn log_line(station: [u8; 32], kind: Kind, count: u64, reason: Reason, field: &str) { - log::warn!( - "macula: kind={} count={count} reason={}{field} (station {})", - kind.name(), - reason.name(), - super::hex(&station) - ); -} - -/// `procedure` cut to [`PROCEDURE_LIMIT`] bytes, on a character boundary. -fn truncated(procedure: &str) -> &str { - let mut end = procedure.len().min(PROCEDURE_LIMIT); - while !procedure.is_char_boundary(end) { - end -= 1; - } - &procedure[..end] -} - -/// `text` with its control characters escaped, `\n`, `\r` and `\t` by name -/// and any other as `\u{..}`, so a warning line never breaks. -fn printable(text: &str) -> String { - let mut out = String::with_capacity(text.len()); - for c in text.chars() { - match c { - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if c.is_control() => out.push_str(&format!("\\u{{{:x}}}", u32::from(c))), - c => out.push(c), - } - } - out -} - -// A panic elsewhere while a lock was held leaves the counts usable. -fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { - mutex.lock().unwrap_or_else(PoisonError::into_inner) -} - -#[cfg(test)] -mod tests { - //! The shared drop warning tests. The names match the Go, .NET and - //! Erlang tests. - use super::*; - use crate::control_channel::fake_station::{capture_logs, logged_about}; - - const SHORT: Duration = Duration::from_millis(100); - - fn warnings() -> DropWarnings { - let warnings = DropWarnings::new(rand::random()); - warnings.set_interval(SHORT); - warnings - } - - #[tokio::test] - async fn a_drop_burst_logs_one_immediate_line_and_one_closing_line_with_the_rest() { - capture_logs(); - let warnings = warnings(); - - for n in 0..5 { - warnings.record( - Kind::RefusedStreamOpen, - Reason::InvalidSignature, - Subject::Procedure(format!("app/stream_{n}")), - ); - } - let immediate = logged_about(&warnings.station); - tokio::time::sleep(SHORT * 3).await; - let lines = logged_about(&warnings.station); - - assert_eq!(immediate.len(), 1, "{immediate:?}"); - assert!( - immediate[0].1.contains( - "kind=refused_stream_open count=1 reason=invalid_signature procedure=app/stream_0" - ), - "{immediate:?}" - ); - assert_eq!(lines.len(), 2, "{lines:?}"); - assert!( - lines[1].1.contains( - "kind=refused_stream_open count=4 reason=invalid_signature procedure=app/stream_4" - ), - "{lines:?}" - ); - } - - #[tokio::test] - async fn a_single_drop_logs_only_the_immediate_line() { - capture_logs(); - let warnings = warnings(); - - warnings.record( - Kind::DroppedReply, - Reason::UnknownCallId, - Subject::CallId([0xab; 16]), - ); - tokio::time::sleep(SHORT * 3).await; - let lines = logged_about(&warnings.station); - - assert_eq!(lines.len(), 1, "{lines:?}"); - assert!( - lines[0] - .1 - .contains("kind=dropped_reply count=1 reason=unknown_call_id call_id=abababab"), - "{lines:?}" - ); - } - - #[tokio::test] - async fn a_procedure_with_a_newline_stays_on_one_log_line() { - capture_logs(); - let warnings = warnings(); - - warnings.record( - Kind::DroppedCall, - Reason::InvalidSignature, - Subject::Procedure("app/a\nkind=forged\u{1b}".to_string()), - ); - let lines = logged_about(&warnings.station); - - assert_eq!(lines.len(), 1, "{lines:?}"); - assert!(!lines[0].1.contains('\n'), "{lines:?}"); - assert!( - lines[0].1.contains("procedure=app/a\\nkind=forged\\u{1b}"), - "{lines:?}" - ); - } - - #[test] - fn a_procedure_is_cut_on_a_character_boundary() { - let procedure = format!("{}é", "a".repeat(PROCEDURE_LIMIT - 1)); - - assert_eq!(truncated(&procedure), "a".repeat(PROCEDURE_LIMIT - 1)); - } -} diff --git a/src/control_channel/fake_station.rs b/src/control_channel/fake_station.rs deleted file mode 100644 index c0e18cb..0000000 --- a/src/control_channel/fake_station.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! An in-memory station on the other end of a session's control stream, for -//! tests of the session reader and of what runs on sessions, such as the -//! pool. The names match the Go and .NET test helpers. - -use super::*; -use tokio::io::DuplexStream; - -pub(crate) const WAIT: Duration = Duration::from_secs(2); -pub(crate) const REALM: [u8; 32] = [7; 32]; - -/// The station end of a session's control stream. What the session -/// writes reaches the station through a pipe that holds one byte, so a -/// station that stops reading stalls the session's write mid-frame, as a -/// station withholding flow-control credit does. -pub(crate) struct FakeStation { - pub(crate) identity: KeyPair, - to_session: DuplexStream, - frames: mpsc::UnboundedReceiver, - reading: watch::Sender, -} - -pub(crate) type Ended = oneshot::Receiver<(SessionEndReason, bool)>; - -pub(crate) fn connect() -> (Arc, FakeStation, Ended) { - connect_with(SEND_TIMEOUT) -} - -pub(crate) fn connect_with(send_timeout: Duration) -> (Arc, FakeStation, Ended) { - let (ended_tx, ended_rx) = oneshot::channel(); - let (channel, station) = connect_ending( - send_timeout, - Box::new(move |reason, closed_here| { - let _ = ended_tx.send((reason.clone(), closed_here)); - }), - ); - (channel, station, ended_rx) -} - -pub(crate) fn connect_ending( - send_timeout: Duration, - on_ended: OnEnded, -) -> (Arc, FakeStation) { - let (session_writes, station_reads) = tokio::io::duplex(1); - let (station_writes, session_reads) = tokio::io::duplex(READ_CHUNK); - let identity = KeyPair::generate(); - let channel = Channel::start( - Box::new(session_reads), - Vec::new(), - Box::new(session_writes), - KeyPair::generate(), - identity.node_id(), - send_timeout, - on_ended, - ); - let (frames_tx, frames) = mpsc::unbounded_channel(); - let (reading, reading_rx) = watch::channel(true); - tokio::spawn(read_session_frames(station_reads, frames_tx, reading_rx)); - ( - channel, - FakeStation { - identity, - to_session: station_writes, - frames, - reading, - }, - ) -} - -async fn read_session_frames( - mut pipe: DuplexStream, - frames: mpsc::UnboundedSender, - mut reading: watch::Receiver, -) { - let mut buf = Vec::new(); - let mut chunk = [0u8; 4096]; - loop { - if reading.wait_for(|reading| *reading).await.is_err() { - return; - } - let Ok(n) = pipe.read(&mut chunk).await else { - return; - }; - if n == 0 { - return; - } - buf.extend_from_slice(&chunk[..n]); - while let Ok(Decoded::Frame(value, consumed)) = frame::decode(&buf) { - buf.drain(..consumed); - if frames.send(value).is_err() { - return; - } - } - } -} - -pub(crate) enum Signer { - Caller, - Other(Box), - Nobody, -} - -impl FakeStation { - pub(crate) async fn send(&mut self, frame: &Value) { - self.send_raw(&frame::encode(frame).expect("the test frame encodes")) - .await; - } - - pub(crate) async fn send_raw(&mut self, bytes: &[u8]) { - self.to_session - .write_all(bytes) - .await - .expect("the session's pipe is open"); - } - - pub(crate) async fn next_frame(&mut self) -> Value { - tokio::time::timeout(WAIT, self.frames.recv()) - .await - .expect("the session sent a frame in time") - .expect("the session's pipe is open") - } - - pub(crate) async fn next(&mut self, frame_type: &str) -> Value { - loop { - let frame = self.next_frame().await; - if text_field(&frame, "frame_type").as_deref() == Some(frame_type) { - return frame; - } - } - } - - pub(crate) async fn reply(&mut self, call: &Value, text: &str) { - let call_id = frame::frame_call_id(call).expect("a CALL carries its call_id"); - let result = frame::result(&frame::ResultSpec::new( - call_id, - Value::text(text), - self.identity.node_id(), - )); - self.send(&result).await; - } - - pub(crate) async fn send_event(&mut self, topic: &str, payload: &str) { - let event = Value::Map(vec![ - (Value::text("frame_type"), Value::text("event")), - (Value::text("realm"), Value::Bytes(REALM.to_vec())), - ( - Value::text("topic"), - Value::Bytes(topic.as_bytes().to_vec()), - ), - ( - Value::text("publisher"), - Value::Bytes(self.identity.node_id().to_vec()), - ), - (Value::text("seq"), Value::Int(1)), - (Value::text("payload"), Value::text(payload)), - (Value::text("delivered_via"), Value::text("direct")), - ]); - self.send(&event).await; - } - - /// An inbound CALL as a station relays one. Returns its call_id. - pub(crate) async fn send_inbound_call(&mut self, procedure: &str, signer: Signer) -> [u8; 16] { - self.send_inbound_call_due(procedure, signer, now_ms() + 5_000) - .await - } - - /// [`send_inbound_call`](Self::send_inbound_call), due at `deadline_ms`. - pub(crate) async fn send_inbound_call_due( - &mut self, - procedure: &str, - signer: Signer, - deadline_ms: i128, - ) -> [u8; 16] { - let caller = KeyPair::generate(); - let spec = CallSpec::new( - rand::random(), - procedure, - REALM, - Value::Null, - deadline_ms, - caller.node_id(), - ); - let unsigned = frame::call(&spec); - let call = match signer { - Signer::Caller => frame::sign(unsigned, &caller), - Signer::Other(other) => frame::sign(unsigned, &other), - Signer::Nobody => unsigned, - }; - self.send(&call).await; - spec.call_id - } - - pub(crate) fn stall_session_writes(&self) { - self.reading.send_replace(false); - } - - pub(crate) fn resume_session_writes(&self) { - self.reading.send_replace(true); - } - - /// Whether the session sends no frame for `wait`. - pub(crate) async fn nothing_sent_within(&mut self, wait: Duration) -> bool { - tokio::time::timeout(wait, self.frames.recv()) - .await - .is_err() - } -} - -pub(crate) fn now_ms() -> i128 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock after 1970") - .as_millis() as i128 -} - -pub(crate) fn call(procedure: &str) -> CallSpec { - CallSpec::new( - rand::random(), - procedure, - REALM, - Value::Null, - now_ms() + 5_000, - KeyPair::generate().node_id(), - ) -} - -pub(crate) fn subscribe(topic: &str) -> SubscribeSpec { - SubscribeSpec::new(topic, REALM, KeyPair::generate().node_id()) -} - -pub(crate) fn publish(topic: &str) -> Value { - let publisher = KeyPair::generate(); - frame::sign( - frame::publish(&frame::PublishSpec::new( - topic, - REALM, - publisher.node_id(), - 1, - Value::text("tick"), - now_ms() as u64, - )), - &publisher, - ) -} - -pub(crate) fn spawn_call( - channel: &Arc, - spec: CallSpec, - timeout: Duration, -) -> JoinHandle> { - let channel = channel.clone(); - tokio::spawn(async move { - channel - .call(&spec, &KeyPair::generate(), timeout, None) - .await - }) -} - -pub(crate) fn reply_text(response: CallResponse) -> String { - match response { - CallResponse::Result { - payload: Value::Text(text), - .. - } => text, - other => panic!("expected a text RESULT, got {other:?}"), - } -} - -pub(crate) fn event_text(event: EventInfo) -> String { - match event.payload { - Value::Text(text) => text, - other => panic!("expected a text payload, got {other:?}"), - } -} - -pub(crate) fn frame_type(frame: &Value) -> String { - text_field(frame, "frame_type").expect("a frame carries its frame_type") -} - -/// Resolves once some other writer holds the turn to write. -pub(crate) async fn turn_taken(channel: &Channel) { - tokio::time::timeout(WAIT, async { - while channel.writer.try_lock().is_ok() { - tokio::task::yield_now().await; - } - }) - .await - .expect("a writer took the turn in time"); -} - -/// Keeps every log line a test run writes, for tests that check what was -/// logged. The log crate takes one logger for the whole process, so every -/// such test installs this same one and picks its own lines by a node id. -struct CapturingLogger; - -static LOGGED: Mutex> = Mutex::new(Vec::new()); - -impl log::Log for CapturingLogger { - fn enabled(&self, _metadata: &log::Metadata<'_>) -> bool { - true - } - - fn log(&self, record: &log::Record<'_>) { - lock(&LOGGED).push((record.level(), record.args().to_string())); - } - - fn flush(&self) {} -} - -pub(crate) fn capture_logs() { - static INSTALLED: OnceLock<()> = OnceLock::new(); - INSTALLED.get_or_init(|| { - let _ = log::set_logger(&CapturingLogger); - log::set_max_level(log::LevelFilter::Info); - }); -} - -/// The captured lines that name `node_id`, in the order they were logged. -pub(crate) fn logged_about(node_id: &[u8; 32]) -> Vec<(log::Level, String)> { - let node_id = hex(node_id); - lock(&LOGGED) - .iter() - .filter(|(_, line)| line.contains(&node_id)) - .cloned() - .collect() -} diff --git a/src/dht.rs b/src/dht.rs deleted file mode 100644 index 8bb36a9..0000000 --- a/src/dht.rs +++ /dev/null @@ -1,829 +0,0 @@ -//! The subset of Macula's signed DHT records that direct-dial resolution -//! needs: `procedure_advertisement` and `station_endpoint` construction, -//! signing, verification, and storage-key derivation, plus thin wrappers -//! around the mesh's `_dht.*` RPC procedures. -//! -//! Ported from `macula-io/macula`'s `src/record/macula_record.erl` and -//! `src/macula.erl` (the `put_record`/`find_record`/`find_records` facade), -//! cross-checked against `macula-go`'s own port of the same reference -//! (`dht/record.go`, `dht/client.go`) — see those files' doc comments for -//! the fuller reasoning behind each field. Only the two record types -//! direct-dial needs are ported; add more constructors here as other -//! direct-dial consumers (streaming, content) are built. -//! -//! **This is a thin RPC client, not a DHT participant.** Every function -//! here just issues an ordinary signed CALL (`_dht.put_record` etc.) to -//! whichever station the given [`Session`] is -//! already connected to — real Kademlia routing, replication, and k-bucket -//! maintenance stay entirely on the relay side (`macula-station`). Nothing -//! in this module talks DHT protocol directly. - -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use crate::cbor::Value; -use crate::connection::{CallError, Session}; -use crate::frame::CallResponse; -use crate::identity::KeyPair; - -/// Record type tags — `macula_record.erl`'s `?TYPE_*` constants. -pub const TYPE_PROCEDURE_ADVERTISEMENT: u8 = 0x06; -pub const TYPE_STATION_ENDPOINT: u8 = 0x12; -pub const TYPE_CONTENT_ANNOUNCEMENT: u8 = 0x11; - -/// Matches `macula_record`'s `?DEFAULT_TTL_MS` (48h) — the TTL a -/// `procedure_advertisement` gets when the caller doesn't specify one. -pub const DEFAULT_TTL: Duration = Duration::from_secs(48 * 60 * 60); - -/// The Ed25519 signature domain separator — `macula_record`'s -/// `?SIG_DOMAIN`. 17 bytes: "macula-v2-record" (16 ASCII) plus a trailing -/// NUL. -const SIG_DOMAIN: &[u8] = b"macula-v2-record\0"; - -/// Mirrors `macula_record.erl`'s envelope map (type/key/version/ -/// created_at/expires_at/payload/signature). `subject_id` is not carried — -/// neither record type this module builds uses it. -#[derive(Debug, Clone)] -pub struct Record { - pub record_type: u8, - /// 32B: envelope signer's Ed25519 pubkey. - pub key: [u8; 32], - /// 16B: UUIDv7. - pub version: [u8; 16], - /// ms since epoch. - pub created_at: i128, - /// ms since epoch. - pub expires_at: i128, - pub payload: Value, - /// 64B once [`sign`] has been called; empty beforehand. - pub signature: Vec, -} - -fn now_ms() -> i128 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock before 1970") - .as_millis() as i128 -} - -fn new_envelope(record_type: u8, key: [u8; 32], payload: Value, ttl: Duration) -> Record { - let created_at = now_ms(); - Record { - record_type, - key, - version: *uuid::Uuid::now_v7().as_bytes(), - created_at, - expires_at: created_at + ttl.as_millis() as i128, - payload, - signature: Vec::new(), - } -} - -/// Builds an UNSIGNED `procedure_advertisement` record naming -/// `serving_station` as `procedure_uri`'s current handler. `procedure_uri` -/// should be the realm-qualified discovery URI (see [`discovery_uri`]), -/// matching `macula_direct_dial`'s own convention — the advertiser and the -/// resolver must derive the identical URI or the DHT storage key -/// ([`procedure_key`]) will not agree. Sign before [`put_record`]. -/// -/// Mirrors `macula_record:procedure_advertisement/3,4`. See -/// [`new_procedure_advertisement_with_cert_chain`] for the `cert_chain` -/// variant. -pub fn new_procedure_advertisement( - advertiser_node: [u8; 32], - procedure_uri: impl Into, - serving_station: [u8; 32], - ttl: Duration, -) -> Record { - let ttl = if ttl.is_zero() { DEFAULT_TTL } else { ttl }; - let payload = Value::Map(vec![ - (Value::text("procedure_uri"), Value::text(procedure_uri)), - ( - Value::text("advertiser_node"), - Value::Bytes(advertiser_node.to_vec()), - ), - ( - Value::text("serving_station"), - Value::Bytes(serving_station.to_vec()), - ), - ]); - new_envelope(TYPE_PROCEDURE_ADVERTISEMENT, advertiser_node, payload, ttl) -} - -/// [`new_procedure_advertisement`] plus an embedded X.509 service-cert -/// chain (leaf-first PEM: leaf ++ org CA), for Slice 7c Direction B -/// managed-realm authorization — see -/// [`cert_chain::verify_advertisement_cert_chain`](crate::cert_chain::verify_advertisement_cert_chain) -/// for the corresponding check. Opt-in: plain [`new_procedure_advertisement`] -/// is unaffected and remains the right choice for unmanaged realms. -pub fn new_procedure_advertisement_with_cert_chain( - advertiser_node: [u8; 32], - procedure_uri: impl Into, - serving_station: [u8; 32], - ttl: Duration, - cert_chain_pem: Vec, -) -> Record { - let mut rec = new_procedure_advertisement(advertiser_node, procedure_uri, serving_station, ttl); - let Value::Map(mut entries) = rec.payload else { - unreachable!("new_procedure_advertisement always returns a Map payload"); - }; - entries.push((Value::text("cert_chain"), Value::Bytes(cert_chain_pem))); - rec.payload = Value::Map(entries); - rec -} - -/// Builds an UNSIGNED `content_announcement` record naming -/// `announcer_node` as reachable at `endpoint` for `mcid`. Sign before -/// [`put_record`]. Mirrors `macula_record:content_announcement/3,4` — see -/// [`ContentAnnouncement`] for which optional metadata fields are not -/// ported. -pub fn new_content_announcement( - announcer_node: [u8; 32], - mcid: crate::manifest::Mcid, - endpoint: impl Into, - ttl: Duration, -) -> Record { - let payload = Value::Map(vec![ - ( - Value::text("announcer_node"), - Value::Bytes(announcer_node.to_vec()), - ), - (Value::text("mcid"), Value::Bytes(mcid.to_vec())), - (Value::text("endpoint"), Value::text(endpoint)), - ]); - new_envelope(TYPE_CONTENT_ANNOUNCEMENT, announcer_node, payload, ttl) -} - -/// Extracts a `content_announcement` record's typed fields, or an error if -/// `r` isn't one or is malformed. Mirrors -/// `macula_record:read_content_announcement/1`. -pub fn read_content_announcement(r: &Record) -> Result { - if r.record_type != TYPE_CONTENT_ANNOUNCEMENT { - return Err(ReadRecordError::WrongRecordType); - } - let announcer_node = bytes32_field(&r.payload, "announcer_node")?; - let mcid: crate::manifest::Mcid = match r.payload.get("mcid") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ReadRecordError::WrongFieldType("mcid"))?, - Some(_) => return Err(ReadRecordError::WrongFieldType("mcid")), - None => return Err(ReadRecordError::MissingField("mcid")), - }; - let endpoint = match r.payload.get("endpoint") { - Some(Value::Text(t)) => t.clone(), - Some(_) => return Err(ReadRecordError::WrongFieldType("endpoint")), - None => return Err(ReadRecordError::MissingField("endpoint")), - }; - Ok(ContentAnnouncement { - announcer_node, - mcid, - endpoint, - }) -} - -/// The exact bytes `macula_record:canonical_unsigned/1` signs and -/// verifies: deterministic CBOR of the envelope map using the COMPACT -/// single-letter keys (t/k/v/c/x/p), signature excluded. This is a -/// DIFFERENT representation from the full-field-name map [`to_rpc_value`] -/// sends as RPC args — the compact form exists only to be signed/verified, -/// never sent on the wire as such. -fn canonical_unsigned(r: &Record) -> Vec { - let entries = Value::Map(vec![ - (Value::text("t"), Value::Int(r.record_type as i128)), - (Value::text("k"), Value::Bytes(r.key.to_vec())), - (Value::text("v"), Value::Bytes(r.version.to_vec())), - (Value::text("c"), Value::Int(r.created_at)), - (Value::text("x"), Value::Int(r.expires_at)), - (Value::text("p"), r.payload.clone()), - ]); - // Signing bytes are protocol-internal and always within the - // deterministic encoder's supported range — an encode failure here - // would mean a payload this module itself built is malformed, which - // is a bug in this module, not a runtime condition to recover from. - crate::cbor::encode(&entries).expect("dht record payload must be encodable") -} - -/// Sets `r.signature` to the Ed25519 signature over -/// `SIG_DOMAIN || canonical_unsigned(r)`, matching `macula_record:sign/2`. -pub fn sign(mut r: Record, id: &KeyPair) -> Record { - let mut msg = SIG_DOMAIN.to_vec(); - msg.extend_from_slice(&canonical_unsigned(&r)); - r.signature = id.sign(&msg).to_vec(); - r -} - -#[derive(Debug, PartialEq, Eq)] -pub enum VerifyError { - InvalidSignature, - Expired, -} - -impl std::fmt::Display for VerifyError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - VerifyError::InvalidSignature => write!(f, "dht: signature invalid"), - VerifyError::Expired => write!(f, "dht: record expired"), - } - } -} - -impl std::error::Error for VerifyError {} - -/// Checks `r`'s Ed25519 signature against its own `key`, then its expiry. -/// Matches `macula_record:verify/1`. Distinguishes [`VerifyError::Expired`] -/// from [`VerifyError::InvalidSignature`] because a caller resolving a -/// record (e.g. `direct_dial`'s retry loop) should retry past a -/// stale-but-once-valid replica, never past a forged one — see -/// `macula_direct_dial.erl`'s `on_endpoint_verified/3` doing exactly this -/// branch. -pub fn verify(r: &Record) -> Result<(), VerifyError> { - let sig: [u8; 64] = r - .signature - .as_slice() - .try_into() - .map_err(|_| VerifyError::InvalidSignature)?; - let mut msg = SIG_DOMAIN.to_vec(); - msg.extend_from_slice(&canonical_unsigned(r)); - if !crate::identity::verify(&msg, &sig, &r.key) { - return Err(VerifyError::InvalidSignature); - } - if r.expires_at > 0 && now_ms() >= r.expires_at { - return Err(VerifyError::Expired); - } - Ok(()) -} - -/// Namespaces `station_endpoint` storage keys so they don't collide with -/// `node_record`, which keys on the same pubkey — `macula_record`'s -/// `?STORAGE_DOMAIN_STATION_ENDPOINT`. -const STORAGE_DOMAIN_STATION_ENDPOINT: &[u8] = b"station_endpoint"; - -/// The DHT storage key for a `procedure_advertisement` by its (already -/// realm-qualified — see [`discovery_uri`]) URI: `SHA-256(uri)`. Matches -/// `macula_record:procedure_key/1`. -pub fn procedure_key(procedure_uri: &str) -> [u8; 32] { - use sha2::{Digest, Sha256}; - Sha256::digest(procedure_uri.as_bytes()).into() -} - -/// The DHT storage key for a station's own `station_endpoint` record: -/// `SHA-256("station_endpoint" || pubkey)`. Matches -/// `macula_record:station_endpoint_key/1`. -pub fn station_endpoint_key(station_pubkey: [u8; 32]) -> [u8; 32] { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(STORAGE_DOMAIN_STATION_ENDPOINT); - hasher.update(station_pubkey); - hasher.finalize().into() -} - -/// The DHT storage key for every `content_announcement` naming `mcid`: -/// `SHA-256(mcid)`. Matches `macula_record:content_key/1`. Consumers use -/// this with [`find_records`] (there may be more than one announcer) -/// before holding any record. -pub fn content_key(mcid: crate::manifest::Mcid) -> [u8; 32] { - use sha2::{Digest, Sha256}; - Sha256::digest(mcid).into() -} - -/// Matches `macula_direct_dial`'s `discovery_uri/2`: the DHT -/// lookup/advertisement key input is `hex(realm) + "/" + procedure`, so the -/// same procedure name under different realms doesn't collide in the DHT. -/// The advertiser and every resolver must derive this identically. -pub fn discovery_uri(realm: [u8; 32], procedure: &str) -> String { - let mut hex_realm = String::with_capacity(64); - for b in realm { - hex_realm.push_str(&format!("{b:02X}")); - } - format!("{hex_realm}/{procedure}") -} - -/// A `procedure_advertisement` record's fields, read out of its payload — -/// mirrors `macula_record:read_procedure_advertisement/1`. `cert_chain` is -/// `None` when the advertisement carries no `cert_chain` field (the common, -/// unmanaged-realm case); see -/// [`cert_chain::verify_advertisement_cert_chain`](crate::cert_chain::verify_advertisement_cert_chain). -#[derive(Debug, Clone)] -pub struct ProcedureAdvertisement { - pub procedure_uri: String, - pub advertiser_node: [u8; 32], - pub serving_station: [u8; 32], - /// Optional: leaf-first PEM bundle, leaf ++ org CA. - pub cert_chain: Option>, -} - -/// A `station_endpoint` record's fields, read out of its payload — mirrors -/// `macula_record:read_station_endpoint/1`. -#[derive(Debug, Clone)] -pub struct StationEndpoint { - pub quic_port: u16, - pub host_advertised: Vec, -} - -/// A `content_announcement` record's fields, read out of its payload — -/// mirrors `macula_record:read_content_announcement/1`. The optional -/// `name`/`size`/`chunk_count` metadata fields -/// (`content_announcement_opts()`) are not ported — direct-dial content -/// fetch doesn't need them to resolve and dial; add them if a future -/// caller needs to prioritize candidates without fetching the manifest. -#[derive(Debug, Clone)] -pub struct ContentAnnouncement { - pub announcer_node: [u8; 32], - pub mcid: crate::manifest::Mcid, - /// A dialable seed URL, e.g. `"https://host:4433"` — matches - /// `macula_client:seed()`'s own format, NOT a `station_endpoint`'s - /// split host/port. - pub endpoint: String, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum ReadRecordError { - WrongRecordType, - MissingField(&'static str), - WrongFieldType(&'static str), -} - -impl std::fmt::Display for ReadRecordError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ReadRecordError::WrongRecordType => write!(f, "dht: unexpected record type"), - ReadRecordError::MissingField(name) => write!(f, "dht: missing field {name:?}"), - ReadRecordError::WrongFieldType(name) => { - write!(f, "dht: field {name:?} has the wrong type") - } - } - } -} - -impl std::error::Error for ReadRecordError {} - -/// Extracts a `procedure_advertisement` record's typed fields, or an error -/// if `r` isn't one or is malformed. -pub fn read_procedure_advertisement(r: &Record) -> Result { - if r.record_type != TYPE_PROCEDURE_ADVERTISEMENT { - return Err(ReadRecordError::WrongRecordType); - } - let procedure_uri = match r.payload.get("procedure_uri") { - Some(Value::Text(t)) => t.clone(), - Some(_) => return Err(ReadRecordError::WrongFieldType("procedure_uri")), - None => return Err(ReadRecordError::MissingField("procedure_uri")), - }; - let advertiser_node = bytes32_field(&r.payload, "advertiser_node")?; - let serving_station = bytes32_field(&r.payload, "serving_station")?; - // Absent is valid, not an error — the common, unmanaged-realm case. - let cert_chain = match r.payload.get("cert_chain") { - Some(Value::Bytes(b)) => Some(b.clone()), - _ => None, - }; - Ok(ProcedureAdvertisement { - procedure_uri, - advertiser_node, - serving_station, - cert_chain, - }) -} - -/// Extracts a `station_endpoint` record's typed fields, or an error if `r` -/// isn't one or is malformed. -pub fn read_station_endpoint(r: &Record) -> Result { - if r.record_type != TYPE_STATION_ENDPOINT { - return Err(ReadRecordError::WrongRecordType); - } - let quic_port = match r.payload.get("quic_port") { - Some(Value::Int(n)) if (1..=65535).contains(n) => *n as u16, - Some(_) => return Err(ReadRecordError::WrongFieldType("quic_port")), - None => return Err(ReadRecordError::MissingField("quic_port")), - }; - // `macula_record.erl`'s `with_host_list/2` puts each host in as a bare - // Erlang binary, unlike every other string field in this record (which - // wraps with `{text, Bin}`) — so on the wire these are CBOR BYTE - // strings (major type 2), not text strings, confirmed against a real - // station's own published record while building `macula-go`'s - // equivalent. Try bytes first, text as a fallback in case a future - // publisher wraps these properly. - let host_advertised = match r.payload.get("host_advertised") { - Some(Value::List(items)) => items - .iter() - .filter_map(|item| match item { - Value::Bytes(b) => String::from_utf8(b.clone()).ok(), - Value::Text(t) => Some(t.clone()), - _ => None, - }) - .collect(), - _ => Vec::new(), - }; - Ok(StationEndpoint { - quic_port, - host_advertised, - }) -} - -fn bytes32_field(v: &Value, name: &'static str) -> Result<[u8; 32], ReadRecordError> { - match v.get(name) { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ReadRecordError::WrongFieldType(name)), - Some(_) => Err(ReadRecordError::WrongFieldType(name)), - None => Err(ReadRecordError::MissingField(name)), - } -} - -// --------------------------------------------------------------------- -// Thin RPC wrappers over the mesh's `_dht.*` procedures. -// --------------------------------------------------------------------- - -/// The all-zero 32-byte realm DHT traffic travels under, protocol-internal -/// infrastructure — matches `macula.erl`'s `?DHT_REALM`. -const DHT_REALM: [u8; 32] = [0u8; 32]; - -/// Matches `macula.erl`'s `?DHT_RECORD_TIMEOUT_MS`. -const DHT_TIMEOUT: Duration = Duration::from_secs(5); - -const PUT_RECORD_PROC: &str = "_dht.put_record"; -const FIND_RECORD_PROC: &str = "_dht.find_record"; -const FIND_RECORDS_PROC: &str = "_dht.find_records"; -const FIND_RECORDS_BY_TYPE_PROC: &str = "_dht.find_records_by_type"; - -/// The FULL-field-name map `macula.erl`'s `put_record/2` sends as a CALL's -/// args (and `find_record`/`find_records` return as a RESULT) — distinct -/// from [`canonical_unsigned`]'s compact single-letter envelope, which -/// exists only to be signed/verified, never sent as such. -fn to_rpc_value(r: &Record) -> Value { - let mut entries = vec![ - (Value::text("type"), Value::Int(r.record_type as i128)), - (Value::text("key"), Value::Bytes(r.key.to_vec())), - (Value::text("version"), Value::Bytes(r.version.to_vec())), - (Value::text("created_at"), Value::Int(r.created_at)), - (Value::text("expires_at"), Value::Int(r.expires_at)), - (Value::text("payload"), r.payload.clone()), - ]; - if r.signature.len() == 64 { - entries.push((Value::text("signature"), Value::Bytes(r.signature.clone()))); - } - Value::Map(entries) -} - -#[derive(Debug, PartialEq, Eq)] -pub enum RecordFromRpcError { - MissingField(&'static str), - WrongFieldType(&'static str), -} - -impl std::fmt::Display for RecordFromRpcError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - RecordFromRpcError::MissingField(name) => write!(f, "dht: missing field {name:?}"), - RecordFromRpcError::WrongFieldType(name) => { - write!(f, "dht: field {name:?} has the wrong type") - } - } - } -} - -impl std::error::Error for RecordFromRpcError {} - -fn record_from_rpc_value(v: &Value) -> Result { - let record_type = match v.get("type") { - Some(Value::Int(n)) if (0..=255).contains(n) => *n as u8, - Some(_) => return Err(RecordFromRpcError::WrongFieldType("type")), - None => return Err(RecordFromRpcError::MissingField("type")), - }; - let key = match v.get("key") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| RecordFromRpcError::WrongFieldType("key"))?, - Some(_) => return Err(RecordFromRpcError::WrongFieldType("key")), - None => return Err(RecordFromRpcError::MissingField("key")), - }; - let version = match v.get("version") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| RecordFromRpcError::WrongFieldType("version"))?, - Some(_) => return Err(RecordFromRpcError::WrongFieldType("version")), - None => return Err(RecordFromRpcError::MissingField("version")), - }; - let created_at = match v.get("created_at") { - Some(Value::Int(n)) => *n, - Some(_) => return Err(RecordFromRpcError::WrongFieldType("created_at")), - None => return Err(RecordFromRpcError::MissingField("created_at")), - }; - let expires_at = match v.get("expires_at") { - Some(Value::Int(n)) => *n, - Some(_) => return Err(RecordFromRpcError::WrongFieldType("expires_at")), - None => return Err(RecordFromRpcError::MissingField("expires_at")), - }; - let payload = v - .get("payload") - .cloned() - .ok_or(RecordFromRpcError::MissingField("payload"))?; - let signature = match v.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - _ => Vec::new(), - }; - Ok(Record { - record_type, - key, - version, - created_at, - expires_at, - payload, - signature, - }) -} - -#[derive(Debug)] -pub enum DhtError { - Call(CallError), - /// The station answered with an ERROR frame — carries its `name`. - Remote(String), - NotFound, - Malformed(RecordFromRpcError), - /// The RESULT payload wasn't the list shape `find_records`/ - /// `find_records_by_type` are expected to return. - ExpectedList, -} - -impl std::fmt::Display for DhtError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - DhtError::Call(e) => write!(f, "dht: {e}"), - DhtError::Remote(name) => write!(f, "dht: station reported {name}"), - DhtError::NotFound => write!(f, "dht: record not found"), - DhtError::Malformed(e) => write!(f, "dht: {e}"), - DhtError::ExpectedList => write!(f, "dht: expected a list reply"), - } - } -} - -impl std::error::Error for DhtError {} - -fn deadline_ms(timeout: Duration) -> i128 { - now_ms() + timeout.as_millis() as i128 -} - -/// Stores a signed record in the mesh DHT. Mirrors `macula:put_record/2` — -/// the relay validates the signature on receipt. -pub async fn put_record(session: &Session, id: &KeyPair, rec: &Record) -> Result<(), DhtError> { - let resp = session - .call( - PUT_RECORD_PROC, - DHT_REALM, - to_rpc_value(rec), - deadline_ms(DHT_TIMEOUT), - id, - DHT_TIMEOUT, - ) - .await - .map_err(DhtError::Call)?; - match resp { - CallResponse::Result { .. } => Ok(()), - CallResponse::Error { name, .. } => Err(DhtError::Remote(name)), - } -} - -/// Fetches one record by its storage key (see [`procedure_key`] / -/// [`station_endpoint_key`]). Returns [`DhtError::NotFound`] if none -/// exists — the caller's signature should still be checked via [`verify`] -/// before the payload is trusted; this function does not verify on the -/// caller's behalf. -pub async fn find_record( - session: &Session, - id: &KeyPair, - key: [u8; 32], -) -> Result { - let args = Value::Map(vec![(Value::text("key"), Value::Bytes(key.to_vec()))]); - let resp = session - .call( - FIND_RECORD_PROC, - DHT_REALM, - args, - deadline_ms(DHT_TIMEOUT), - id, - DHT_TIMEOUT, - ) - .await - .map_err(DhtError::Call)?; - match resp { - CallResponse::Result { payload, .. } => { - if matches!(&payload, Value::Text(t) if t == "not_found") { - return Err(DhtError::NotFound); - } - record_from_rpc_value(&payload).map_err(DhtError::Malformed) - } - CallResponse::Error { name, .. } => Err(DhtError::Remote(name)), - } -} - -/// Fetches every record stored at `key` — the full signer-deduped multiset -/// (e.g. every `procedure_advertisement` for one procedure). Each record's -/// signature should be verified via [`verify`] before its payload is -/// trusted; this function does not verify on the caller's behalf. -pub async fn find_records( - session: &Session, - id: &KeyPair, - key: [u8; 32], -) -> Result, DhtError> { - let args = Value::Map(vec![(Value::text("key"), Value::Bytes(key.to_vec()))]); - let resp = session - .call( - FIND_RECORDS_PROC, - DHT_REALM, - args, - deadline_ms(DHT_TIMEOUT), - id, - DHT_TIMEOUT, - ) - .await - .map_err(DhtError::Call)?; - records_list_from_response(resp) -} - -/// Returns every record of `typ` currently visible from the station this -/// session is connected to. Coverage depends on that station's own view of -/// the DHT. Mirrors `macula:find_records_by_type/2`. -pub async fn find_records_by_type( - session: &Session, - id: &KeyPair, - typ: u8, -) -> Result, DhtError> { - let args = Value::Map(vec![(Value::text("type"), Value::Int(typ as i128))]); - let resp = session - .call( - FIND_RECORDS_BY_TYPE_PROC, - DHT_REALM, - args, - deadline_ms(DHT_TIMEOUT), - id, - DHT_TIMEOUT, - ) - .await - .map_err(DhtError::Call)?; - records_list_from_response(resp) -} - -fn records_list_from_response(resp: CallResponse) -> Result, DhtError> { - match resp { - CallResponse::Result { payload, .. } => match payload { - Value::List(items) => Ok(items - .iter() - .filter_map(|item| record_from_rpc_value(item).ok()) - .collect()), - _ => Err(DhtError::ExpectedList), - }, - CallResponse::Error { name, .. } => Err(DhtError::Remote(name)), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample_advertisement(id: &KeyPair) -> Record { - let station: [u8; 32] = [7u8; 32]; - let uri = discovery_uri([0u8; 32], "test.procedure"); - let rec = new_procedure_advertisement(id.node_id(), uri, station, DEFAULT_TTL); - sign(rec, id) - } - - #[test] - fn sign_then_verify_round_trips() { - let id = KeyPair::generate(); - let rec = sample_advertisement(&id); - assert_eq!(rec.signature.len(), 64); - assert!(verify(&rec).is_ok()); - } - - #[test] - fn verify_rejects_a_tampered_payload() { - let id = KeyPair::generate(); - let mut rec = sample_advertisement(&id); - // Flip the record's advertised type after signing -- the signature - // covers record_type, so this must invalidate it. - rec.record_type = TYPE_STATION_ENDPOINT; - assert_eq!(verify(&rec), Err(VerifyError::InvalidSignature)); - } - - #[test] - fn verify_rejects_a_signature_from_the_wrong_signer() { - let signer = KeyPair::generate(); - let mut rec = sample_advertisement(&signer); - // The envelope's own `key` field claims a DIFFERENT signer than - // the one that actually produced `signature` -- verify checks the - // signature against `key`, so this must fail. - rec.key = KeyPair::generate().public_bytes(); - assert_eq!(verify(&rec), Err(VerifyError::InvalidSignature)); - } - - #[test] - fn verify_rejects_an_expired_record() { - let id = KeyPair::generate(); - let station: [u8; 32] = [7u8; 32]; - let uri = discovery_uri([0u8; 32], "test.procedure"); - // A TTL that has already elapsed by the time verify() runs. - let rec = new_procedure_advertisement(id.node_id(), uri, station, Duration::from_millis(1)); - std::thread::sleep(Duration::from_millis(20)); - let rec = sign(rec, &id); - assert_eq!(verify(&rec), Err(VerifyError::Expired)); - } - - #[test] - fn canonical_unsigned_is_deterministic() { - let id = KeyPair::generate(); - let rec = sample_advertisement(&id); - // Re-deriving the same bytes from the same (already-built) record - // must always agree -- this is exactly what a verifier on the - // other end of the wire independently recomputes. - assert_eq!(canonical_unsigned(&rec), canonical_unsigned(&rec)); - } - - #[test] - fn procedure_key_differs_by_realm() { - let a = procedure_key(&discovery_uri([0u8; 32], "same.name")); - let b = procedure_key(&discovery_uri([1u8; 32], "same.name")); - assert_ne!( - a, b, - "the same bare procedure name under different realms must not collide" - ); - } - - #[test] - fn discovery_uri_matches_expected_hex_format() { - let uri = discovery_uri([0u8; 32], "hecate_mail.initiate_mailbox"); - assert_eq!( - uri, - format!("{}/hecate_mail.initiate_mailbox", "00".repeat(32)) - ); - } - - #[test] - fn read_procedure_advertisement_round_trips_the_payload() { - let id = KeyPair::generate(); - let station: [u8; 32] = [9u8; 32]; - let uri = "0".repeat(64) + "/some.procedure"; - let rec = new_procedure_advertisement(id.node_id(), uri.clone(), station, DEFAULT_TTL); - let read = read_procedure_advertisement(&rec).expect("should read back cleanly"); - assert_eq!(read.procedure_uri, uri); - assert_eq!(read.advertiser_node, id.node_id()); - assert_eq!(read.serving_station, station); - } - - #[test] - fn read_procedure_advertisement_rejects_the_wrong_record_type() { - let id = KeyPair::generate(); - let station: [u8; 32] = [9u8; 32]; - let mut rec = new_procedure_advertisement(id.node_id(), "x/y", station, DEFAULT_TTL); - rec.record_type = TYPE_STATION_ENDPOINT; - assert!(matches!( - read_procedure_advertisement(&rec), - Err(ReadRecordError::WrongRecordType) - )); - } - - #[test] - fn station_endpoint_host_advertised_reads_byte_string_entries() { - // macula_record.erl's with_host_list/2 puts each host in as a bare - // Erlang binary -- on the wire these decode as CBOR byte strings - // (major type 2), not text, confirmed against a real station's own - // published record while building macula-go's equivalent. This - // guards that this crate reads that shape too, not just a - // hypothetical text-wrapped one. - let rec = Record { - record_type: TYPE_STATION_ENDPOINT, - key: [1u8; 32], - version: [0u8; 16], - created_at: 0, - expires_at: 0, - payload: Value::Map(vec![ - (Value::text("quic_port"), Value::Int(4433)), - ( - Value::text("host_advertised"), - Value::List(vec![Value::Bytes(b"203.0.113.5".to_vec())]), - ), - ]), - signature: Vec::new(), - }; - let ep = read_station_endpoint(&rec).expect("should read the byte-string host"); - assert_eq!(ep.quic_port, 4433); - assert_eq!(ep.host_advertised, vec!["203.0.113.5".to_string()]); - } - - #[test] - fn to_rpc_value_and_record_from_rpc_value_round_trip() { - let id = KeyPair::generate(); - let rec = sample_advertisement(&id); - let rpc_value = to_rpc_value(&rec); - let back = record_from_rpc_value(&rpc_value).expect("should decode cleanly"); - assert_eq!(back.record_type, rec.record_type); - assert_eq!(back.key, rec.key); - assert_eq!(back.version, rec.version); - assert_eq!(back.created_at, rec.created_at); - assert_eq!(back.expires_at, rec.expires_at); - assert_eq!(back.signature, rec.signature); - // The payload survives the RPC round trip byte-for-byte-equivalent - // even though it isn't compared via canonical_unsigned here. - assert!(verify(&back).is_ok()); - } -} diff --git a/src/direct_dial.rs b/src/direct_dial.rs deleted file mode 100644 index 8fb998b..0000000 --- a/src/direct_dial.rs +++ /dev/null @@ -1,3786 +0,0 @@ -//! Direct-dial resolve-and-call: resolving a signed `procedure_advertisement` -//! DHT record and its serving station's own signed `station_endpoint`, then -//! dialing that station in one hop — instead of depending on ordinary -//! advertise-gossip having propagated a route between whichever two -//! stations happen to be involved. -//! -//! Ported from `macula-io/macula`'s `macula_direct_dial.erl`, cross-checked -//! against `macula-go`'s own port of the same reference -//! (`directdial/directdial.go`) — see that file's doc for the fuller -//! reasoning behind each design choice made here. -//! -//! **Trust model** (see `macula_direct_dial.erl`'s module doc for the full -//! reasoning): every candidate `procedure_advertisement` must carry a valid -//! Ed25519 signature before its `serving_station` is trusted at all, and -//! the resolved `station_endpoint` must be signed by the station itself. -//! The actual QUIC dial trusts neither the TLS certificate (a production -//! station's TLS is terminated by an unrelated PKI) nor nothing — trust is -//! enforced at the application layer, by checking the freshly dialed -//! session's own signature-verified HELLO identity against the exact -//! pubkey the signed DHT chain resolved. -//! -//! **Candidates:** every advertisement (or content announcement) that -//! verifies is a candidate, tried in the order the DHT returned them. A -//! candidate whose endpoint record doesn't resolve, whose dial fails, or -//! whose dialed identity doesn't match is skipped for the next one, because -//! nothing has reached the provider yet. So is a CALL that failed before it -//! was sent, because its session had ended or its turn to write didn't come -//! in time; its station may be tried again on a later pass. Once a CALL or -//! STREAM_OPEN has gone out, its result is the call's result and it is never -//! sent again. When a -//! query fails, no candidate qualifies, or every one failed before sending, -//! the DHT is queried again with a backoff of 100 ms doubling to 1 s; within -//! one call a -//! station that already failed is dialed again only once its advertisement -//! or endpoint record has changed. The call's `timeout` bounds all of it, -//! and each candidate gets a share of what remains for its endpoint lookup -//! and dial. At the deadline, the most recent candidate failure is returned -//! as it was raised; a later query that finds nothing, or fails, never -//! replaces it. When no candidate was ever tried, the call returns why the -//! latest answered query found none, else the latest failed query's error, -//! else a timeout: an absence nobody observed is never reported. -//! -//! **Reuse:** a station keeps one connection per identity and closes the -//! older one when a newer one arrives. So when this process already has a -//! session open to the provider's station under the same identity -//! (`resolve_via` itself, or a [`Pool`](crate::pool::Pool) link), [`call`] -//! and its variants run on that session, and [`open_stream_direct`], -//! [`put_direct`] and [`get_direct`] run on it on a dedicated QUIC stream of -//! their own, instead of dialing, and never close it. They need no -//! `station_endpoint` lookup either. A session direct dial dialed is shared -//! the same way: each request using it holds a lease, and it closes when the -//! last one is released (see [`SessionLease`]). -//! -//! `cert_chain`-based org/realm authorization (Slice 7c Direction B, -//! `macula_record:verify_advertisement_cert_chain/3` on the Erlang side) is -//! opt-in here too, matching the reference and `macula-go`'s own port — -//! see [`resolve_with_cert_chain`]/[`call_with_cert_chain`]/ -//! [`advertise_direct_with_cert_chain`]. Plain [`resolve`]/[`call`]/ -//! [`advertise_direct`] are completely unaffected. - -use std::collections::HashMap; -use std::convert::Infallible; -use std::future::{ready, Future}; -use std::time::Duration; - -use tokio::time::Instant; - -use crate::cbor::Value; -use crate::cert_chain::{self, CertChainError}; -use crate::connection::{self, FrameStream, Session}; -use crate::content; -use crate::dht::{self, DhtError, Record}; -use crate::frame::{CallResponse, StreamMode}; -use crate::identity::KeyPair; -use crate::manifest::Mcid; -use crate::open_sessions::{self, Leased, Leases}; -use crate::stream::{self, StreamHandle}; -use crate::transport::Trust; - -fn now_ms() -> i128 { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock before 1970") - .as_millis() as i128 -} - -/// Matches `macula_direct_dial.erl`'s `?RESOLVE_RETRY_MS` — a record just -/// published on the provider's station has not necessarily replicated to -/// the resolving station yet, so the first miss is not treated as failure. -/// The endpoint lookup retries at this cadence within a candidate's share, -/// and re-queries start at it. -const RESOLVE_RETRY_DELAY: Duration = Duration::from_millis(100); - -/// Re-queries back off from [`RESOLVE_RETRY_DELAY`], doubling up to this -/// cap, so a call waiting out a missing or refusing provider doesn't keep -/// loading the DHT. -const MAX_REQUERY_PAUSE: Duration = Duration::from_secs(1); - -/// Every candidate gets at least this much of the remaining time for its -/// endpoint lookup and dial, or all of it when less than this remains. -const MIN_CANDIDATE_SHARE: Duration = Duration::from_secs(1); - -/// The time budget [`resolve`] and [`resolve_with_cert_chain`] get, since -/// neither takes a timeout of its own. Matches `macula-go`'s -/// `DefaultResolveTimeout`. -const DEFAULT_RESOLVE_TIMEOUT: Duration = Duration::from_secs(10); - -#[derive(Debug)] -pub enum ResolveError { - /// Every `find_records` attempt came back empty after retrying past - /// DHT propagation lag. - ProcedureNotAdvertised, - /// Records were found, but none had a valid signature. - NoTrustedAdvertisement, - /// A resolved station published no reachable (or no longer valid) - /// `station_endpoint` after retrying. - StationEndpointNotFound, - /// A `station_endpoint` record was found under the right key, but its - /// signer didn't match the station it's supposed to describe. - StationEndpointSignerMismatch, - /// The station's `station_endpoint` record verified but named no - /// dialable address, and it was the latest answer before the deadline. - /// Matches `macula_direct_dial`'s `malformed_station_endpoint`. - MalformedStationEndpoint, - Dht(DhtError), - /// [`resolve_with_cert_chain`] only: at least one candidate - /// advertisement's envelope signature verified (otherwise - /// [`ResolveError::NoTrustedAdvertisement`] would apply instead), but - /// none passed cert-chain authorization for the expected org — carries - /// the specific [`CertChainError`] from the LAST candidate tried - /// (absent chain, wrong org, untrusted chain, etc.). - NoAuthorizedAdvertisement(CertChainError), - /// The timeout ran out before any DHT lookup was answered or failed, or - /// before any candidate could be tried. - Timeout, -} - -impl std::fmt::Display for ResolveError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ResolveError::ProcedureNotAdvertised => { - write!( - f, - "direct_dial: procedure has no direct-dial advertisement in the DHT" - ) - } - ResolveError::NoTrustedAdvertisement => write!( - f, - "direct_dial: every candidate advertisement failed signature verification" - ), - ResolveError::StationEndpointNotFound => write!( - f, - "direct_dial: resolved station published no reachable station_endpoint" - ), - ResolveError::StationEndpointSignerMismatch => { - write!(f, "direct_dial: station_endpoint signer mismatch") - } - ResolveError::MalformedStationEndpoint => write!( - f, - "direct_dial: the station_endpoint record names no dialable address" - ), - ResolveError::Dht(e) => write!(f, "direct_dial: {e}"), - ResolveError::NoAuthorizedAdvertisement(e) => write!( - f, - "direct_dial: no candidate advertisement is cert-chain-authorized for the expected org: {e}" - ), - ResolveError::Timeout => write!( - f, - "direct_dial: the timeout ran out before a provider was resolved" - ), - } - } -} - -impl std::error::Error for ResolveError {} - -/// One resolved direct-dial target: the station's own node id plus a -/// dialable host/port. -#[derive(Debug, Clone)] -pub struct Resolved { - pub station: [u8; 32], - pub host: String, - pub port: u16, -} - -/// The two DHT lookups direct-dial resolution makes, behind a trait so the -/// resolution logic runs unchanged against a fake DHT in tests. -pub(crate) trait DhtLookups { - async fn find_records(&mut self, key: [u8; 32]) -> Result, DhtError>; - async fn find_record(&mut self, key: [u8; 32]) -> Result; -} - -/// The real lookups: DHT queries over `session`. -struct Via<'a> { - session: &'a Session, - id: &'a KeyPair, -} - -impl DhtLookups for Via<'_> { - async fn find_records(&mut self, key: [u8; 32]) -> Result, DhtError> { - dht::find_records(self.session, self.id, key).await - } - - async fn find_record(&mut self, key: [u8; 32]) -> Result { - dht::find_record(self.session, self.id, key).await - } -} - -/// The realm CA and org an advertisement's cert chain must satisfy, on the -/// `*_with_cert_chain` paths. -#[derive(Clone, Copy)] -pub(crate) struct CertChainCheck<'a> { - pub(crate) realm_ca_pem: &'a [u8], - pub(crate) expected_org: &'a str, -} - -/// Why a resolving call failed: resolution itself, the dial of the last -/// candidate tried, or the request once it was sent. Each public function -/// maps this to its own error type. -#[derive(Debug)] -pub(crate) enum Failure { - Resolve(ResolveError), - Dial(DE), - Request(RE), -} - -/// Why a direct content fetch failed; mapped to [`GetDirectError`]. -#[derive(Debug)] -pub(crate) enum ContentFailure { - Dht(DhtError), - NotAnnounced, - EndpointParse(String), - Dial(DE), - Fetch(RE), - /// The deadline cut a transfer off; carries the failure before it. - Timeout(Option>>), -} - -/// One call's time budget. It covers resolution, every endpoint lookup, -/// every dial and the request itself. -#[derive(Clone, Copy, Debug)] -pub(crate) struct CallDeadline { - at: Instant, -} - -impl CallDeadline { - pub(crate) fn after(budget: Duration) -> Self { - Self { - at: Instant::now() + budget, - } - } - - /// Less than a millisecond left counts as none, so a pause at the - /// deadline edge never shrinks to a zero-length sleep that lets the - /// re-query loop spin. - pub(crate) fn remaining(&self) -> Duration { - let left = self.at.saturating_duration_since(Instant::now()); - if left >= Duration::from_millis(1) { - left - } else { - Duration::ZERO - } - } - - pub(crate) fn passed(&self) -> bool { - self.remaining().is_zero() - } - - /// The next candidate's share of what remains, for its endpoint lookup - /// and dial: an even split across the candidates not yet tried, but - /// never less than [`MIN_CANDIDATE_SHARE`] unless less than that - /// remains. - pub(crate) fn share_for(&self, untried: usize) -> CallDeadline { - let remaining = self.remaining(); - let even = remaining / u32::try_from(untried.max(1)).unwrap_or(u32::MAX); - CallDeadline::after(even.max(remaining.min(MIN_CANDIDATE_SHARE))) - } -} - -/// A qualified advertisement: its signer and record version identify the -/// candidate, and `station` serves it. -struct ProcedureCandidate { - signer: [u8; 32], - version: [u8; 16], - station: [u8; 32], -} - -/// A qualified content announcement: its announcer and record version -/// identify the candidate, and `endpoint` is where to dial it. -struct ContentCandidate { - announcer: [u8; 32], - version: [u8; 16], - endpoint: String, -} - -/// A candidate that failed before sending, within one call: the version of -/// the record that made it a candidate, the endpoint record version it -/// failed on (`None` when none was found), and the error. -struct Remembered { - record_version: [u8; 16], - endpoint_version: Option<[u8; 16]>, - error: F, -} - -/// The failure a call reports at its deadline, the first of: the remembered -/// failure of the last candidate tried, why the latest answered query found -/// no candidate, and the latest failed query's error. With none of them the -/// call reports a timeout. -enum Last { - Candidate([u8; 32]), - /// A request that failed before it was sent, which is not remembered. - NotSent(F), - NoneQualified(F), - LookupFailed(F), -} - -fn take_last( - last: Option>, - failures: &mut HashMap<[u8; 32], Remembered>, -) -> Option { - match last? { - Last::Candidate(key) => failures.remove(&key).map(|remembered| remembered.error), - Last::NotSent(failure) | Last::NoneQualified(failure) | Last::LookupFailed(failure) => { - Some(failure) - } - } -} - -/// Records why an answered query found no candidate: over a failed query's -/// error, never over a candidate's failure. -fn record_none_qualified(last: &mut Option>, reason: F) { - if !matches!(last, Some(Last::Candidate(_) | Last::NotSent(_))) { - *last = Some(Last::NoneQualified(reason)); - } -} - -/// Records a failed query's error, only while no candidate has failed and no -/// query was answered. -fn record_lookup_failure(last: &mut Option>, error: F) { - if matches!(last, None | Some(Last::LookupFailed(_))) { - *last = Some(Last::LookupFailed(error)); - } -} - -/// What one DHT query came to. -enum Lookup { - Answered(Vec), - Failed(DhtError), - /// Cut off by the deadline: it learned nothing. - CutOff, -} - -async fn query(dht: &mut D, key: [u8; 32], deadline: CallDeadline) -> Lookup { - match tokio::time::timeout(deadline.remaining(), dht.find_records(key)).await { - Ok(Ok(recs)) => Lookup::Answered(recs), - Ok(Err(e)) => Lookup::Failed(e), - Err(_) => Lookup::CutOff, - } -} - -/// Whether a request that failed was never sent, so another candidate may -/// take it without the provider running it twice. -pub(crate) trait NotSent { - fn not_sent(&self) -> bool; -} - -impl NotSent for connection::CallError { - fn not_sent(&self) -> bool { - connection::CallError::not_sent(self) - } -} - -/// A stream is never opened elsewhere: its STREAM_OPEN may already be out. -impl NotSent for stream::OpenError { - fn not_sent(&self) -> bool { - false - } -} - -impl NotSent for Infallible { - fn not_sent(&self) -> bool { - match *self {} - } -} - -/// Resolves `procedure`'s provider, dials it with `dial`, and sends it one -/// request with `request`, all within `timeout` — see the module doc's -/// "Candidates" for how providers are tried in turn. -/// -/// A candidate whose station `already_open` has a session for gets the -/// request on that session, with no endpoint lookup and no dial. A request -/// that fails before it was sent ([`NotSent`]) lets the next candidate take -/// it and is not remembered, so its station may be tried again on a later -/// pass; any other outcome of a request ends the call. -/// -/// `dial` and `request` are plain closures returning futures (not async -/// closures) so the public functions built on this keep `Send` futures. -#[allow(clippy::too_many_arguments)] -pub(crate) async fn reach_procedure( - dht: &mut D, - realm: [u8; 32], - procedure: &str, - cert_chain: Option>, - mut already_open: impl FnMut(&[u8; 32]) -> Option, - mut dial: impl FnMut(Resolved, Duration) -> DF, - mut request: impl FnMut(S, Duration) -> RF, - timeout: Duration, -) -> Result> -where - D: DhtLookups, - RE: NotSent, - DF: Future>, - RF: Future>, -{ - let deadline = CallDeadline::after(timeout); - let key = dht::procedure_key(&dht::discovery_uri(realm, procedure)); - let mut failures: HashMap<[u8; 32], Remembered>> = HashMap::new(); - let mut last: Option>> = None; - let mut pause = RESOLVE_RETRY_DELAY; - loop { - let candidates = match query(dht, key, deadline).await { - Lookup::Answered(recs) => { - let (candidates, unresolved) = if recs.is_empty() { - (Vec::new(), ResolveError::ProcedureNotAdvertised) - } else if let Some(check) = cert_chain { - authorized_advertisements(&recs, check) - } else { - trusted_advertisements(&recs) - }; - if candidates.is_empty() { - record_none_qualified(&mut last, Failure::Resolve(unresolved)); - } - candidates - } - // A failed query teaches nothing, and is retried like one that - // found no candidate. - Lookup::Failed(e) => { - record_lookup_failure(&mut last, Failure::Resolve(ResolveError::Dht(e))); - Vec::new() - } - Lookup::CutOff => Vec::new(), - }; - for (tried, candidate) in candidates.iter().enumerate() { - if deadline.passed() { - break; - } - if let Some(target) = reused(&mut already_open, &candidate.station) { - match request(target, deadline.remaining()).await { - Err(e) if e.not_sent() => { - last = Some(Last::NotSent(Failure::Request(e))); - continue; - } - result => return result.map_err(Failure::Request), - } - } - let share = deadline.share_for(candidates.len() - tried); - // An unchanged advertisement that already failed gets a single - // endpoint lookup, and its station is dialed again only if that - // lookup shows a different endpoint version. A lookup that got - // no answer teaches nothing. - let failed_on = failures - .get(&candidate.signer) - .filter(|remembered| remembered.record_version == candidate.version) - .map(|remembered| remembered.endpoint_version); - let lookup = - lookup_station_endpoint(dht, candidate.station, share, failed_on.is_none()).await; - if failed_on - .is_some_and(|failed_on| !lookup.answered || lookup.seen_version == failed_on) - { - last = Some(Last::Candidate(candidate.signer)); - continue; - } - let failure = match lookup.outcome { - Ok(resolved) => match dial(resolved, share.remaining()).await { - Ok(target) => match request(target, deadline.remaining()).await { - Err(e) if e.not_sent() => { - last = Some(Last::NotSent(Failure::Request(e))); - continue; - } - result => return result.map_err(Failure::Request), - }, - Err(e) => Failure::Dial(e), - }, - Err(e) => Failure::Resolve(e), - }; - failures.insert( - candidate.signer, - Remembered { - record_version: candidate.version, - endpoint_version: lookup.seen_version, - error: failure, - }, - ); - last = Some(Last::Candidate(candidate.signer)); - } - if deadline.passed() { - break; - } - tokio::time::sleep(pause.min(deadline.remaining())).await; - pause = (pause * 2).min(MAX_REQUERY_PAUSE); - if deadline.passed() { - break; - } - } - // With nothing observed at all, the deadline ran out before any query - // was answered or any candidate could be tried. - Err(take_last(last, &mut failures).unwrap_or(Failure::Resolve(ResolveError::Timeout))) -} - -/// Resolves a known station's endpoint, dials it with `dial`, and sends it -/// one request with `request`. `timeout` bounds the endpoint lookup and the -/// dial; the request gets whatever remains and may ignore it. When -/// `already_open` has a session for the station, the request runs on it -/// instead, with no endpoint lookup and no dial. -pub(crate) async fn reach_station( - dht: &mut D, - station: [u8; 32], - already_open: impl FnOnce(&[u8; 32]) -> Option, - dial: impl FnOnce(Resolved, Duration) -> DF, - request: impl FnOnce(S, Duration) -> RF, - timeout: Duration, -) -> Result> -where - D: DhtLookups, - DF: Future>, - RF: Future>, -{ - let deadline = CallDeadline::after(timeout); - if let Some(target) = reused(already_open, &station) { - return request(target, deadline.remaining()) - .await - .map_err(Failure::Request); - } - let resolved = lookup_station_endpoint(dht, station, deadline, true) - .await - .outcome - .map_err(Failure::Resolve)?; - let target = dial(resolved, deadline.remaining()) - .await - .map_err(Failure::Dial)?; - request(target, deadline.remaining()) - .await - .map_err(Failure::Request) -} - -/// Finds `mcid`'s announced providers and fetches the content from the -/// first one that serves it, dialing each with `dial` and fetching with -/// `fetch`, all within `timeout`. Any failure moves on to the next -/// provider, since a fetch is verified against its MCID and safe to repeat -/// elsewhere; a provider that failed is skipped on later passes unless its -/// announcement changed. A provider whose station `already_open` has a -/// session for is fetched from on that session, with no dial. -pub(crate) async fn fetch_content( - dht: &mut D, - mcid: Mcid, - mut already_open: impl FnMut(&[u8; 32]) -> Option, - mut dial: impl FnMut(Resolved, Duration) -> DF, - mut fetch: impl FnMut(S, Duration) -> FF, - timeout: Duration, -) -> Result> -where - D: DhtLookups, - DF: Future>, - FF: Future>, -{ - let deadline = CallDeadline::after(timeout); - let key = dht::content_key(mcid); - let mut failures: HashMap<[u8; 32], Remembered>> = HashMap::new(); - let mut last: Option>> = None; - let mut pause = RESOLVE_RETRY_DELAY; - loop { - let providers = match query(dht, key, deadline).await { - Lookup::Answered(recs) => { - let providers = trusted_content_providers(&recs); - if providers.is_empty() { - record_none_qualified(&mut last, ContentFailure::NotAnnounced); - } - providers - } - // A failed query teaches nothing, and is retried like one that - // found no provider. - Lookup::Failed(e) => { - record_lookup_failure(&mut last, ContentFailure::Dht(e)); - Vec::new() - } - Lookup::CutOff => Vec::new(), - }; - for (tried, provider) in providers.iter().enumerate() { - if deadline.passed() { - break; - } - let share = deadline.share_for(providers.len() - tried); - let already_failed = failures - .get(&provider.announcer) - .is_some_and(|remembered| remembered.record_version == provider.version); - if !already_failed { - let failure = - match reach_provider(provider, &mut already_open, &mut dial, share).await { - Err(failure) => failure, - Ok(target) => match tokio::time::timeout( - deadline.remaining(), - fetch(target, deadline.remaining()), - ) - .await - { - Ok(Ok(content)) => return Ok(content), - Ok(Err(e)) => ContentFailure::Fetch(e), - Err(_) => { - return Err(ContentFailure::Timeout( - take_last(last, &mut failures).map(Box::new), - )) - } - }, - }; - failures.insert( - provider.announcer, - Remembered { - record_version: provider.version, - endpoint_version: None, - error: failure, - }, - ); - } - last = Some(Last::Candidate(provider.announcer)); - } - if deadline.passed() { - break; - } - tokio::time::sleep(pause.min(deadline.remaining())).await; - pause = (pause * 2).min(MAX_REQUERY_PAUSE); - if deadline.passed() { - break; - } - } - // With nothing observed at all, the deadline ran out before any query - // was answered or any provider could be tried. - Err(take_last(last, &mut failures).unwrap_or(ContentFailure::Timeout(None))) -} - -/// Reaches one content provider: on the session `already_open` has for its -/// station when there is one, otherwise by dialing its announced endpoint -/// within `share`. -async fn reach_provider( - provider: &ContentCandidate, - already_open: impl FnOnce(&[u8; 32]) -> Option, - dial: impl FnOnce(Resolved, Duration) -> DF, - share: CallDeadline, -) -> Result> -where - DF: Future>, -{ - if let Some(target) = reused(already_open, &provider.announcer) { - return Ok(target); - } - let (host, port) = parse_seed_url(&provider.endpoint) - .ok_or_else(|| ContentFailure::EndpointParse(provider.endpoint.clone()))?; - let resolved = Resolved { - station: provider.announcer, - host, - port, - }; - dial(resolved, share.remaining()) - .await - .map_err(ContentFailure::Dial) -} - -/// The session already open to `station` that a request runs on instead of -/// dialing, if any. -fn reused(already_open: impl FnOnce(&[u8; 32]) -> Option, station: &[u8; 32]) -> Option { - already_open(station) -} - -/// No session to reuse, for resolution alone. -fn no_open_session(_station: &[u8; 32]) -> Option { - None -} - -/// Resolution alone: the "dial" and the "request" hand the resolved -/// endpoint straight back. -pub(crate) async fn resolve_within( - dht: &mut D, - realm: [u8; 32], - procedure: &str, - cert_chain: Option>, - timeout: Duration, -) -> Result { - reach_procedure( - dht, - realm, - procedure, - cert_chain, - no_open_session::, - |resolved: Resolved, _share: Duration| ready(Ok::<_, Infallible>(resolved)), - |resolved: Resolved, _remaining: Duration| ready(Ok::<_, Infallible>(resolved)), - timeout, - ) - .await - .map_err(|failure| match failure { - Failure::Resolve(e) => e, - Failure::Dial(never) | Failure::Request(never) => match never {}, - }) -} - -/// Finds `procedure`'s currently-advertised serving station and its -/// dialable host/port, retrying past DHT propagation lag for up to 10 -/// seconds. `realm` and `procedure` must match exactly what the provider -/// passed to [`advertise_direct`] (or the Erlang equivalent) — the -/// discovery URI they derive must agree. `session` is used only to query -/// the DHT; it does not need to be connected to the same station that will -/// end up serving the call. The first candidate whose station endpoint -/// resolves is returned. -pub async fn resolve( - session: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, -) -> Result { - resolve_within( - &mut Via { session, id }, - realm, - procedure, - None, - DEFAULT_RESOLVE_TIMEOUT, - ) - .await -} - -/// Every advertisement whose signature and expiry verify and whose payload -/// parses, in DHT order, and the error to report if none does. -fn trusted_advertisements(recs: &[Record]) -> (Vec, ResolveError) { - let candidates = recs - .iter() - .filter_map(|rec| { - dht::verify(rec).ok()?; - let adv = dht::read_procedure_advertisement(rec).ok()?; - Some(ProcedureCandidate { - signer: rec.key, - version: rec.version, - station: adv.serving_station, - }) - }) - .collect(); - (candidates, ResolveError::NoTrustedAdvertisement) -} - -/// [`resolve`] plus Slice 7c Direction B managed-realm authorization: only -/// an advertisement whose embedded cert chain validates to `realm_ca_pem` -/// and names `expected_org` is trusted. Opt-in — [`resolve`] itself is -/// unaffected and remains the right choice for unmanaged realms. -pub async fn resolve_with_cert_chain( - session: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, - realm_ca_pem: &[u8], - expected_org: &str, -) -> Result { - resolve_within( - &mut Via { session, id }, - realm, - procedure, - Some(CertChainCheck { - realm_ca_pem, - expected_org, - }), - DEFAULT_RESOLVE_TIMEOUT, - ) - .await -} - -/// [`trusted_advertisements`] plus the cert-chain check. Matches Go's -/// `firstAuthorizedAdvertisement`: if every candidate fails even the plain -/// envelope-signature check, report [`ResolveError::NoTrustedAdvertisement`] -/// (same as the plain path); only report -/// [`ResolveError::NoAuthorizedAdvertisement`] once at least one candidate's -/// signature verified but none passed cert-chain authorization. -fn authorized_advertisements( - recs: &[Record], - check: CertChainCheck<'_>, -) -> (Vec, ResolveError) { - let mut candidates = Vec::new(); - let mut last_cert_err: Option = None; - for rec in recs { - if dht::verify(rec).is_err() { - continue; - } - match cert_chain::verify_advertisement_cert_chain( - check.realm_ca_pem, - rec, - check.expected_org, - ) { - Ok(()) => { - if let Ok(adv) = dht::read_procedure_advertisement(rec) { - candidates.push(ProcedureCandidate { - signer: rec.key, - version: rec.version, - station: adv.serving_station, - }); - } - } - Err(e) => last_cert_err = Some(e), - } - } - let unresolved = match last_cert_err { - Some(e) => ResolveError::NoAuthorizedAdvertisement(e), - None => ResolveError::NoTrustedAdvertisement, - }; - (candidates, unresolved) -} - -/// One `station_endpoint` lookup within a budget: the resolved endpoint or -/// why it failed, the version of the last record the DHT returned, and -/// whether the DHT answered at all (a record or not_found) rather than -/// failing or being cut off. -struct EndpointLookup { - outcome: Result, - seen_version: Option<[u8; 16]>, - answered: bool, -} - -/// What the latest answered `station_endpoint` lookup found instead of a -/// usable record. -#[derive(Clone, Copy)] -enum Answered { - NotFound, - Malformed, -} - -/// With `retry_within_budget`, a lookup that found no usable record (absent, -/// expired, or naming no dialable address) or that failed is looked up again -/// every [`RESOLVE_RETRY_DELAY`] until `budget` runs out — the DHT can hand -/// back a replica that hasn't been evicted yet even though the station's own -/// current publish is live. A record that doesn't verify ends the lookup. -/// When no usable record turns up, the lookup reports what it observed, as -/// `macula_direct_dial`'s `endpoint_recorded/3` does: the latest answered -/// lookup (not found, or the malformed record), else the latest failed -/// lookup's error, else a timeout. -async fn lookup_station_endpoint( - dht: &mut D, - station: [u8; 32], - budget: CallDeadline, - retry_within_budget: bool, -) -> EndpointLookup { - let key = dht::station_endpoint_key(station); - let mut seen_version = None; - let mut answered = None; - let mut failed = None; - loop { - match tokio::time::timeout(budget.remaining(), dht.find_record(key)).await { - Ok(Ok(rec)) => { - seen_version = Some(rec.version); - // The station_endpoint record for `station` must be SIGNED BY - // `station` itself — checking the signature and that the - // signer is exactly `station`, not just any valid signature, - // is what makes pinning the dial's expected identity - // meaningful. - if rec.key != station { - return EndpointLookup { - outcome: Err(ResolveError::StationEndpointSignerMismatch), - seen_version, - answered: true, - }; - } - match dht::verify(&rec) { - Ok(()) => match read_endpoint(station, &rec) { - Some(resolved) => { - return EndpointLookup { - outcome: Ok(resolved), - seen_version, - answered: true, - } - } - None => answered = Some(Answered::Malformed), - }, - Err(dht::VerifyError::Expired) => answered = Some(Answered::NotFound), - Err(_) => { - return EndpointLookup { - outcome: Err(ResolveError::NoTrustedAdvertisement), - seen_version, - answered: true, - } - } - } - } - Ok(Err(DhtError::NotFound)) => answered = Some(Answered::NotFound), - // A failed lookup teaches nothing: looked up again like an absent - // record. - Ok(Err(e)) => failed = Some(e), - // Cut off by the budget: nothing learned. - Err(_) => {} - } - if !retry_within_budget || budget.passed() { - let unresolved = match (answered, failed) { - (Some(Answered::NotFound), _) => ResolveError::StationEndpointNotFound, - (Some(Answered::Malformed), _) => ResolveError::MalformedStationEndpoint, - (None, Some(e)) => ResolveError::Dht(e), - (None, None) => ResolveError::Timeout, - }; - return EndpointLookup { - outcome: Err(unresolved), - seen_version, - answered: answered.is_some(), - }; - } - tokio::time::sleep(RESOLVE_RETRY_DELAY.min(budget.remaining())).await; - } -} - -/// The dialable address a verified `station_endpoint` record names, or -/// `None` when it names none. -fn read_endpoint(station: [u8; 32], rec: &Record) -> Option { - let ep = dht::read_station_endpoint(rec).ok()?; - let host = ep.host_advertised.into_iter().next()?; - Some(Resolved { - station, - host, - port: ep.quic_port, - }) -} - -#[derive(Debug)] -pub enum CallError { - Resolve(ResolveError), - Dial(connection::HandshakeError), - /// The dialed peer's own signature-verified HELLO identity didn't - /// match the pubkey the signed DHT chain resolved — a trust violation, - /// not a retryable error. - TrustViolation { - resolved: [u8; 32], - dialed: [u8; 32], - }, - Call(connection::CallError), -} - -impl std::fmt::Display for CallError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - CallError::Resolve(e) => write!(f, "{e}"), - CallError::Dial(e) => write!(f, "direct_dial: dialing resolved station: {e}"), - CallError::TrustViolation { resolved, dialed } => write!( - f, - "direct_dial: trust violation -- resolved station {} but the dialed peer proved identity {}", - hex_of(resolved), - hex_of(dialed) - ), - CallError::Call(e) => write!(f, "direct_dial: {e}"), - } - } -} - -impl std::error::Error for CallError {} - -fn hex_of(b: &[u8; 32]) -> String { - b.iter().map(|byte| format!("{byte:02x}")).collect() -} - -fn call_failure(failure: Failure) -> CallError { - match failure { - Failure::Resolve(e) => CallError::Resolve(e), - Failure::Dial(DialAndVerifyError::Dial(e)) => CallError::Dial(e), - Failure::Dial(DialAndVerifyError::TrustViolation { resolved, dialed }) => { - CallError::TrustViolation { resolved, dialed } - } - Failure::Request(e) => CallError::Call(e), - } -} - -/// The live dial every direct-dial shape uses: [`dial_and_verify`] against -/// a resolved endpoint within `timeout`. -async fn dial_verified( - resolved: Resolved, - id: &KeyPair, - timeout: Duration, -) -> Result { - dial_and_verify(&resolved.host, resolved.port, resolved.station, id, timeout).await -} - -/// Sends one CALL on the target with whatever remains of the deadline, then -/// gives back the target's lease, closing a dialed session when no other -/// direct-dial request still uses it. -async fn call_then_release( - target: StationTarget, - remaining: Duration, - id: &KeyPair, - procedure: &str, - realm: [u8; 32], - payload: Value, - ucan_token: Option>, -) -> Result { - let deadline_ms = now_ms() + remaining.as_millis() as i128; - let (result, last) = run_then_release(target, move |session: Session| async move { - match ucan_token { - None => { - session - .call(procedure, realm, payload, deadline_ms, id, remaining) - .await - } - Some(token) => { - session - .call_with_ucan(procedure, realm, payload, deadline_ms, id, remaining, token) - .await - } - } - }) - .await; - close_last(last, id).await; - result -} - -/// Resolves `procedure`'s provider via direct-dial (through `resolve_via`, -/// used only to query the DHT) and calls it there, in one hop. The provider -/// must have advertised via [`advertise_direct`] (or the Erlang -/// `macula_response:advertise_direct/6,7`) — a plain `advertise` publishes -/// no discoverable record and the call returns -/// [`ResolveError::ProcedureNotAdvertised`]. -/// -/// `timeout` bounds the whole call: finding the provider, each candidate's -/// endpoint lookup and dial, and the CALL itself. See the module doc's -/// "Candidates" for how providers are tried in turn. -/// -/// The call runs on a session this process already has open to the -/// provider's station under `id` when there is one (`resolve_via`, a -/// [`Pool`](crate::pool::Pool) link, or a session direct dial dialed for -/// another request), and otherwise dials one, which closes once no -/// direct-dial request still uses it. A CALL that fails before it was sent -/// is tried on the next candidate; one that was or may have been sent is -/// returned. -/// -/// The dial itself uses [`Trust::Insecure`] (no TLS verification) because -/// trust is enforced at the application layer instead — see the module -/// doc's "Trust model". After the dial, the freshly connected session's own -/// signature-verified HELLO identity is checked against the exact pubkey -/// the signed DHT chain resolved; a mismatch is -/// [`CallError::TrustViolation`], and that candidate is skipped. -pub async fn call( - resolve_via: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, - payload: Value, - timeout: Duration, -) -> Result { - reach_procedure( - &mut Via { - session: resolve_via, - id, - }, - realm, - procedure, - None, - move |station: &[u8; 32]| open_session_to(id, station), - move |resolved: Resolved, share: Duration| dial_target(resolved, id, share), - move |target: StationTarget, remaining: Duration| { - call_then_release( - target, - remaining, - id, - procedure, - realm, - payload.clone(), - None, - ) - }, - timeout, - ) - .await - .map_err(call_failure) -} - -/// [`call`], presenting `ucan_token` to a provider gated with -/// `{ucan_required, Issuer}`. Every hecate-om capability is advertised via -/// [`advertise_direct`], so this is the only way a UCAN-gated capability -/// is reachable through this crate at all -- [`call`] itself has no token -/// parameter, and [`Session::call_with_ucan`] is the plain, non-direct -/// path, which cannot resolve a direct-dial-only advertisement to begin -/// with. -pub async fn call_with_ucan( - resolve_via: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, - payload: Value, - timeout: Duration, - ucan_token: Vec, -) -> Result { - reach_procedure( - &mut Via { - session: resolve_via, - id, - }, - realm, - procedure, - None, - move |station: &[u8; 32]| open_session_to(id, station), - move |resolved: Resolved, share: Duration| dial_target(resolved, id, share), - move |target: StationTarget, remaining: Duration| { - call_then_release( - target, - remaining, - id, - procedure, - realm, - payload.clone(), - Some(ucan_token.clone()), - ) - }, - timeout, - ) - .await - .map_err(call_failure) -} - -/// [`call`], resolved via [`resolve_with_cert_chain`] instead of -/// [`resolve`] — see both for the full contract. Opt-in managed-realm -/// authorization; [`call`] itself is unaffected. -#[allow(clippy::too_many_arguments)] -pub async fn call_with_cert_chain( - resolve_via: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, - realm_ca_pem: &[u8], - expected_org: &str, - payload: Value, - timeout: Duration, -) -> Result { - reach_procedure( - &mut Via { - session: resolve_via, - id, - }, - realm, - procedure, - Some(CertChainCheck { - realm_ca_pem, - expected_org, - }), - move |station: &[u8; 32]| open_session_to(id, station), - move |resolved: Resolved, share: Duration| dial_target(resolved, id, share), - move |target: StationTarget, remaining: Duration| { - call_then_release( - target, - remaining, - id, - procedure, - realm, - payload.clone(), - None, - ) - }, - timeout, - ) - .await - .map_err(call_failure) -} - -/// Publishes a signed `procedure_advertisement` naming `session`'s own -/// currently-connected station (`session.station.node_id`) as `procedure`'s -/// server, discoverable by any caller's [`resolve`]/[`call`]. Mirrors -/// `macula_response:advertise_direct/6,7` + -/// `macula_direct_dial:publish_advertisement/4,5` — unlike the Erlang -/// reference's pool (many links, one chosen by `connected_station/1`), a -/// [`Session`] is always exactly one connection, so there is no -/// link-selection step: the session's own verified HELLO identity IS the -/// serving station. -/// -/// **Sends the ordinary ADVERTISE frame first, then publishes the DHT -/// record** — matching `macula_response:advertise_direct/7`'s own body -/// exactly (`case advertise(Pool, Realm, Procedure, Module, Args, Opts) of -/// {ok, Sup} -> ... macula_direct_dial:publish_advertisement(...)`). The -/// DHT record is an ADDITIONAL discovery path for a caller on a different -/// station to skip inter-station gossip propagation — it is not a -/// substitute for the station actually knowing to route inbound CALLs -/// here. **Found live, 2026-08-30**: an earlier version of this function -/// (and its `macula-go` port, same gap, not yet fixed there as of this -/// writing) published only the DHT record — a direct-dial caller could -/// resolve and dial the right station, but the station itself had never -/// been told to route the call anywhere, so every call still failed with -/// `unknown_next_peer` despite a perfectly valid, resolvable, trusted -/// advertisement. Caught by a live test that, unlike the earlier -/// direct-dial verification, actually tried to get a real RESULT back -/// instead of accepting `unknown_next_peer` as the expected terminal state. -/// -/// Unlike the Erlang SDK's supervised `macula_response`, this does not -/// itself keep anything alive — it does not spawn a responder process, so -/// a caller still needs its own [`Session::serve_one_call`](crate::connection::Session::serve_one_call) -/// loop to actually answer what gets routed here. A station's registration -/// for a procedure does not survive the connection that sent it being -/// replaced, so a long-lived server needs to call this again on its own -/// schedule; see [`keep_advertised_direct`] for that loop. -pub async fn advertise_direct( - session: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, - ttl: Duration, -) -> Result<(), AdvertiseDirectError> { - let advertise_spec = crate::frame::AdvertiseSpec::new(realm, procedure, id.node_id()); - session - .advertise(&advertise_spec, id) - .await - .map_err(AdvertiseDirectError::Advertise)?; - - let uri = dht::discovery_uri(realm, procedure); - let rec = dht::new_procedure_advertisement(id.node_id(), uri, session.station.node_id, ttl); - let rec = dht::sign(rec, id); - dht::put_record(session, id, &rec) - .await - .map_err(AdvertiseDirectError::Dht) -} - -/// [`advertise_direct`] plus an embedded X.509 service-cert chain, for -/// Slice 7c Direction B managed-realm authorization — see -/// [`resolve_with_cert_chain`]/[`call_with_cert_chain`] for the -/// corresponding checks. Opt-in: plain [`advertise_direct`] is unaffected. -pub async fn advertise_direct_with_cert_chain( - session: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, - ttl: Duration, - cert_chain_pem: Vec, -) -> Result<(), AdvertiseDirectError> { - let advertise_spec = crate::frame::AdvertiseSpec::new(realm, procedure, id.node_id()); - session - .advertise(&advertise_spec, id) - .await - .map_err(AdvertiseDirectError::Advertise)?; - - let uri = dht::discovery_uri(realm, procedure); - let rec = dht::new_procedure_advertisement_with_cert_chain( - id.node_id(), - uri, - session.station.node_id, - ttl, - cert_chain_pem, - ); - let rec = dht::sign(rec, id); - dht::put_record(session, id, &rec) - .await - .map_err(AdvertiseDirectError::Dht) -} - -#[derive(Debug)] -pub enum AdvertiseDirectError { - /// The ordinary station-side ADVERTISE frame failed to send. - Advertise(connection::SendError), - /// The ordinary ADVERTISE succeeded, but publishing the direct-dial - /// DHT record failed — the procedure IS now reachable via ordinary - /// advertise-gossip, just not via direct-dial resolution. - Dht(DhtError), -} - -impl std::fmt::Display for AdvertiseDirectError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - AdvertiseDirectError::Advertise(e) => write!(f, "direct_dial: sending ADVERTISE: {e}"), - AdvertiseDirectError::Dht(e) => write!(f, "direct_dial: {e}"), - } - } -} - -impl std::error::Error for AdvertiseDirectError {} - -/// Calls [`advertise_direct`] immediately, then again every `interval`, -/// until `stop` resolves. Rust has nothing equivalent to -/// `macula_response`'s `reuse_sup` to worry about here, because -/// [`advertise_direct`] (unlike Erlang's `advertise/5`, which spawns a real -/// per-call OTP supervisor) is already a stateless, side-effect-free-on- -/// repeat async function: nothing is created per tick that could leak — -/// same reasoning `macula-go`'s `KeepAdvertisedDirect` already applied -/// and verified live. -/// -/// `interval` should leave real margin before `ttl` expires — production -/// practice in `hecate-om`'s own capability re-advertise loop (the actual -/// consumer of `advertise_direct`'s `reuse_sup` option on the Erlang side) -/// uses a 4x margin: a 30s republish interval against a 120s record TTL. -/// -/// A failed tick (network blip, connection genuinely dead, etc.) is -/// reported via `on_error` but does NOT stop the loop; it tries again at -/// the next interval regardless, matching `hecate-om`'s own log-and-continue -/// practice around every DHT publish. This loop cannot detect or repair a -/// dead `session` on its own — if its underlying connection has actually -/// gone down, every tick will keep failing the same way until `stop` -/// resolves; reconnecting a dead session is a separate, larger concern this -/// does not attempt to solve. -/// -/// 8 parameters: a target (`session`/`realm`/`procedure`), a re-advertise -/// schedule (`id`/`ttl`/`interval`), and two independent callbacks -/// (`stop`/`on_error`) with no natural sub-grouping — folding any of them -/// into a synthetic struct would relocate the count, not reduce it. -#[allow(clippy::too_many_arguments)] -pub async fn keep_advertised_direct( - session: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, - ttl: Duration, - interval: Duration, - stop: F, - on_error: impl Fn(AdvertiseDirectError), -) where - F: Future, -{ - tokio::pin!(stop); - let mut ticker = tokio::time::interval(interval); - loop { - tokio::select! { - _ = &mut stop => return, - _ = ticker.tick() => { - if let Err(e) = advertise_direct(session, id, realm, procedure, ttl).await { - on_error(e); - } - } - } - } -} - -/// The dial-then-pin sequence every direct-dial call shape needs after -/// resolving: dial `resolved`'s host:port, then check the freshly -/// connected session's own signature-verified HELLO identity against -/// `resolved.station` — factored out here because [`call`]/ -/// [`open_stream_direct`]/[`put_direct`]/[`get_direct`] all need the -/// identical sequence against a station identity that isn't necessarily -/// reached via [`resolve`]. -#[derive(Debug)] -pub enum DialAndVerifyError { - Dial(connection::HandshakeError), - /// The dialed peer's own signature-verified HELLO identity didn't - /// match the pubkey the signed DHT chain resolved — a trust - /// violation, not a retryable error. - TrustViolation { - resolved: [u8; 32], - dialed: [u8; 32], - }, -} - -impl std::fmt::Display for DialAndVerifyError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - DialAndVerifyError::Dial(e) => write!(f, "direct_dial: dialing resolved station: {e}"), - DialAndVerifyError::TrustViolation { resolved, dialed } => write!( - f, - "direct_dial: trust violation -- resolved station {} but the dialed peer proved identity {}", - hex_of(resolved), - hex_of(dialed) - ), - } - } -} - -impl std::error::Error for DialAndVerifyError {} - -async fn dial_and_verify( - host: &str, - port: u16, - station: [u8; 32], - id: &KeyPair, - timeout: Duration, -) -> Result { - let target = tokio::time::timeout( - timeout, - connection::connect_leased(host, port, Trust::Insecure, id), - ) - .await - .unwrap_or(Err(connection::HandshakeError::Timeout)) - .map_err(DialAndVerifyError::Dial)?; - - if target.station.node_id != station { - let dialed = target.station.node_id; - target.close("trust_violation", None, id).await; - return Err(DialAndVerifyError::TrustViolation { - resolved: station, - dialed, - }); - } - Ok(target) -} - -/// The session a request runs on, and whether the request holds a lease on -/// it. A session direct dial dialed comes with the request's lease; a -/// session this process already had open under its owner, such as -/// `resolve_via` or a pool link, comes with none, so the request never -/// releases or closes it. -struct StationTarget { - session: S, - leased: bool, -} - -impl StationTarget { - /// The target for a session direct dial just dialed, holding the lease - /// the dial took. - fn dialed(session: S) -> Self { - let leased = session.leases().is_some(); - Self { session, leased } - } - - /// The target for reusing a session found open to a station: without a - /// lease when its owner opened it, with a new lease when direct dial - /// dialed it, and none at all when its last lease was already released. - fn reuse(found: S) -> Option { - let leased = match found.leases() { - None => false, - Some(leases) if leases.try_lease() => true, - Some(_) => return None, - }; - Some(Self { - session: found, - leased, - }) - } - - /// Gives back the request's lease, if it holds one. Returns the session - /// when that was its last lease, for the caller to close. - fn release_lease(self) -> Option { - let last = self.leased && self.session.leases().is_some_and(Leases::release); - last.then_some(self.session) - } -} - -impl StationTarget { - async fn open_dedicated_stream(&self) -> Result { - self.session.open_dedicated_stream().await - } - - /// Gives back the request's lease, closing a session direct dial dialed - /// when no other direct-dial request still uses it. - async fn release(self, id: &KeyPair) { - close_last(self.release_lease(), id).await; - } -} - -/// Runs `request` on the target's session, then gives the target's lease -/// back whatever the outcome. Returns the request's result, and the session -/// when that was its last lease, for the caller to close. -async fn run_then_release( - target: StationTarget, - request: impl FnOnce(S) -> Fut, -) -> (T, Option) -where - S: Leased + Clone, - Fut: Future, -{ - let result = request(target.session.clone()).await; - (result, target.release_lease()) -} - -/// Closes `last`, a session whose last lease was just given back. -async fn close_last(last: Option, id: &KeyPair) { - if let Some(session) = last { - session.close("normal", None, id).await; - } -} - -/// Dials a resolved station for a request that could have reused an open -/// session instead. -async fn dial_target( - resolved: Resolved, - id: &KeyPair, - timeout: Duration, -) -> Result { - dial_verified(resolved, id, timeout) - .await - .map(StationTarget::dialed) -} - -/// The connection of a session this process already has open to `station` -/// under `id`, such as `resolve_via` or a pool link, reused instead of -/// dialing: a second connection under the same identity would make the -/// station close that session. A session direct dial dialed for another -/// request is reused with a lease of its own, and not at all once it is -/// closing. -fn open_session_to(id: &KeyPair, station: &[u8; 32]) -> Option { - open_sessions::live() - .find(id.node_id(), *station) - .and_then(|open| StationTarget::reuse(Session::from_open(open))) -} - -/// A stream [`open_stream_direct`] opened, and its use of the session it -/// runs on. -pub struct OpenedStream { - pub stream: StreamHandle, - /// Release it once the stream is done. - pub lease: SessionLease, -} - -/// A direct-dial stream's use of the session it runs on. A session direct -/// dial dialed stays open while any direct-dial request still uses it and -/// closes when the last one is released; a session this process already had -/// open under its owner, such as `resolve_via` or a -/// [`Pool`](crate::pool::Pool) link, stays open. A lease dropped without -/// being released leaves a dialed session open until its last handle is -/// gone, which then closes it without a GOODBYE. -pub struct SessionLease { - target: StationTarget, -} - -impl SessionLease { - /// The session the stream runs on. - pub fn session(&self) -> &Session { - &self.target.session - } - - /// Gives back this use of the session, closing a session direct dial - /// dialed when no other direct-dial request still uses it. - pub async fn release(self, id: &KeyPair) { - self.target.release(id).await; - } -} - -#[derive(Debug)] -pub enum OpenStreamDirectError { - Resolve(ResolveError), - Dial(DialAndVerifyError), - Open(stream::OpenError), -} - -impl std::fmt::Display for OpenStreamDirectError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - OpenStreamDirectError::Resolve(e) => write!(f, "{e}"), - OpenStreamDirectError::Dial(e) => write!(f, "{e}"), - OpenStreamDirectError::Open(e) => write!(f, "direct_dial: open stream: {e}"), - } - } -} - -impl std::error::Error for OpenStreamDirectError {} - -fn open_stream_failure( - failure: Failure, -) -> OpenStreamDirectError { - match failure { - Failure::Resolve(e) => OpenStreamDirectError::Resolve(e), - Failure::Dial(e) => OpenStreamDirectError::Dial(e), - Failure::Request(e) => OpenStreamDirectError::Open(e), - } -} - -/// Opens the stream on the target and hands it to the caller with the -/// target's lease. Gives the lease back only if the open itself fails. -async fn open_stream_on( - target: StationTarget, - id: &KeyPair, - procedure: &str, - realm: [u8; 32], - mode: StreamMode, - args: Value, - deadline_ms: i128, -) -> Result { - let opened = match target.open_dedicated_stream().await { - Ok(dedicated) => { - StreamHandle::open_on(dedicated, procedure, realm, mode, args, deadline_ms, id).await - } - Err(e) => Err(stream::OpenError::OpenStream(e)), - }; - match opened { - Ok(stream) => Ok(OpenedStream { - stream, - lease: SessionLease { target }, - }), - Err(e) => { - target.release(id).await; - Err(e) - } - } -} - -/// Resolves `procedure`'s provider via direct-dial (through `resolve_via`, -/// used only to query the DHT) and opens a stream there, in one hop — the -/// streaming-RPC counterpart to [`call`]. The provider must have advertised -/// via [`advertise_direct`]: -/// streaming's provider side (`macula_streamer.erl`) shares the identical -/// `procedure_advertisement` mechanism RPC uses (confirmed against -/// `macula_streamer.erl`/`macula_stream_sink.erl`'s own `advertise_direct`/ -/// `start_link_direct` — both are `macula_response:advertise_direct`/ -/// `macula_direct_dial:call_stream` under the hood, nothing stream-specific -/// added), so no separate stream-shaped advertise function exists or is -/// needed. -/// -/// `timeout` bounds finding the provider, each candidate's endpoint lookup -/// and dial, and opening the stream; `deadline_ms` is the stream's own -/// deadline, sent to the provider. A failure once the station is reached is -/// never retried elsewhere, because STREAM_OPEN may already be out. -/// -/// The stream runs on a session this process already has open to the -/// provider's station under `id` when there is one (`resolve_via`, a -/// [`Pool`](crate::pool::Pool) link, or a session direct dial dialed for -/// another request), on a dedicated QUIC stream of its own; otherwise direct -/// dial dials a session for it. Release [`OpenedStream::lease`] once the -/// stream is done. -#[allow(clippy::too_many_arguments)] -pub async fn open_stream_direct( - resolve_via: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, - mode: StreamMode, - args: Value, - deadline_ms: i128, - timeout: Duration, -) -> Result { - reach_procedure( - &mut Via { - session: resolve_via, - id, - }, - realm, - procedure, - None, - move |station: &[u8; 32]| open_session_to(id, station), - move |resolved: Resolved, share: Duration| dial_target(resolved, id, share), - move |target: StationTarget, _remaining: Duration| { - open_stream_on( - target, - id, - procedure, - realm, - mode, - args.clone(), - deadline_ms, - ) - }, - timeout, - ) - .await - .map_err(open_stream_failure) -} - -/// [`open_stream_direct`], resolved via [`resolve_with_cert_chain`] -/// instead of [`resolve`] — see both for the full contract. Opt-in -/// managed-realm authorization; [`open_stream_direct`] itself is -/// unaffected. -#[allow(clippy::too_many_arguments)] -pub async fn open_stream_direct_with_cert_chain( - resolve_via: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, - realm_ca_pem: &[u8], - expected_org: &str, - mode: StreamMode, - args: Value, - deadline_ms: i128, - timeout: Duration, -) -> Result { - reach_procedure( - &mut Via { - session: resolve_via, - id, - }, - realm, - procedure, - Some(CertChainCheck { - realm_ca_pem, - expected_org, - }), - move |station: &[u8; 32]| open_session_to(id, station), - move |resolved: Resolved, share: Duration| dial_target(resolved, id, share), - move |target: StationTarget, _remaining: Duration| { - open_stream_on( - target, - id, - procedure, - realm, - mode, - args.clone(), - deadline_ms, - ) - }, - timeout, - ) - .await - .map_err(open_stream_failure) -} - -#[derive(Debug)] -pub enum PutDirectError { - Resolve(ResolveError), - Dial(DialAndVerifyError), - Put(content::PutError), -} - -impl std::fmt::Display for PutDirectError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PutDirectError::Resolve(e) => write!(f, "{e}"), - PutDirectError::Dial(e) => write!(f, "{e}"), - PutDirectError::Put(e) => write!(f, "direct_dial: {e}"), - } - } -} - -impl std::error::Error for PutDirectError {} - -/// Stores `data` on the target, then gives back the target's lease, closing -/// a dialed session when no other direct-dial request still uses it. -async fn put_then_release( - target: StationTarget, - id: &KeyPair, - data: &[u8], - name: String, -) -> Result { - let (result, last) = run_then_release(target, move |session: Session| async move { - match session.open_dedicated_stream().await { - Ok(dedicated) => content::put_on(dedicated, data, name, id).await, - Err(e) => Err(content::PutError::OpenStream(e)), - } - }) - .await; - close_last(last, id).await; - result -} - -/// Stores `data` at a KNOWN `station` directly, in one hop, instead of -/// going through whatever station `resolve_via` happens to be connected -/// to. Mirrors `macula_feeder:start_link_direct/5,6`, which — unlike -/// procedure/stream direct-dial — takes the target station's pubkey -/// directly rather than resolving one via a `procedure_advertisement`: -/// content has no "procedure" to advertise, so there is nothing to -/// resolve here beyond the station's own `station_endpoint`. -/// `resolve_via` is used only to query the DHT for `station`'s -/// `station_endpoint`; it does not need to already be connected to -/// `station`. -/// -/// `timeout` bounds the station's endpoint lookup and the dial; the upload -/// itself runs without one, as before. -/// -/// When this process already has a session open to `station` under `id` -/// (`resolve_via` itself, or a [`Pool`](crate::pool::Pool) link), the -/// upload runs on that session, on a dedicated QUIC stream, with no -/// endpoint lookup or dial, and the session stays open. Otherwise direct -/// dial dials a session for the upload and closes it afterwards, unless -/// another direct-dial request still uses it. -pub async fn put_direct( - resolve_via: &Session, - id: &KeyPair, - station: [u8; 32], - data: &[u8], - name: impl Into, - timeout: Duration, -) -> Result { - let name = name.into(); - reach_station( - &mut Via { - session: resolve_via, - id, - }, - station, - move |station: &[u8; 32]| open_session_to(id, station), - move |resolved: Resolved, remaining: Duration| dial_target(resolved, id, remaining), - move |target: StationTarget, _remaining: Duration| put_then_release(target, id, data, name), - timeout, - ) - .await - .map_err(|failure| match failure { - Failure::Resolve(e) => PutDirectError::Resolve(e), - Failure::Dial(e) => PutDirectError::Dial(e), - Failure::Request(e) => PutDirectError::Put(e), - }) -} - -/// `mcid` has no live, verifiable `content_announcement` in the DHT — -/// either nobody announced it (common: a single-block content put alone is -/// never announced, matching `macula_content_transfer:put_single_block/3`), -/// or every candidate found failed signature/self-consistency -/// verification. -#[derive(Debug)] -pub struct ContentNotAnnounced; - -impl std::fmt::Display for ContentNotAnnounced { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "direct_dial: content has no verifiable announcement in the DHT" - ) - } -} - -impl std::error::Error for ContentNotAnnounced {} - -#[derive(Debug)] -pub enum GetDirectError { - Dht(DhtError), - NotAnnounced(ContentNotAnnounced), - /// A `content_announcement`'s `endpoint` field wasn't a dialable - /// `host:port` or URL. - EndpointParse(String), - Dial(DialAndVerifyError), - Get(content::GetError), - /// The timeout ran out before the content arrived: during a content - /// transfer, or before any provider lookup was answered. `last` is the - /// failure before a cut-off transfer, if any — for example the provider - /// tried first serving content that didn't verify — and is also this - /// error's [`source`](std::error::Error::source). - Timeout { - last: Option>, - }, -} - -impl std::fmt::Display for GetDirectError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - GetDirectError::Dht(e) => write!(f, "direct_dial: find content providers: {e}"), - GetDirectError::NotAnnounced(e) => write!(f, "{e}"), - GetDirectError::EndpointParse(endpoint) => { - write!( - f, - "direct_dial: content provider endpoint {endpoint:?}: not a URL or host:port" - ) - } - GetDirectError::Dial(e) => write!(f, "{e}"), - GetDirectError::Get(e) => write!(f, "direct_dial: {e}"), - GetDirectError::Timeout { last: None } => { - write!( - f, - "direct_dial: the timeout ran out before the content arrived" - ) - } - GetDirectError::Timeout { last: Some(last) } => write!( - f, - "direct_dial: the timeout ran out during the content transfer, after: {last}" - ), - } - } -} - -impl std::error::Error for GetDirectError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - GetDirectError::Timeout { last: Some(last) } => Some(last.as_ref()), - _ => None, - } - } -} - -fn get_direct_failure( - failure: ContentFailure, -) -> GetDirectError { - match failure { - ContentFailure::Dht(e) => GetDirectError::Dht(e), - ContentFailure::NotAnnounced => GetDirectError::NotAnnounced(ContentNotAnnounced), - ContentFailure::EndpointParse(endpoint) => GetDirectError::EndpointParse(endpoint), - ContentFailure::Dial(e) => GetDirectError::Dial(e), - ContentFailure::Fetch(e) => GetDirectError::Get(e), - ContentFailure::Timeout(last) => GetDirectError::Timeout { - last: last.map(|failure| Box::new(get_direct_failure(*failure))), - }, - } -} - -/// Fetches `mcid` on the target, then gives back the target's lease, closing -/// a dialed session when no other direct-dial request still uses it. -async fn get_then_release( - target: StationTarget, - id: &KeyPair, - mcid: Mcid, -) -> Result, content::GetError> { - let (result, last) = run_then_release(target, move |session: Session| async move { - match session.open_dedicated_stream().await { - Ok(dedicated) => content::get_on(dedicated, mcid, id).await, - Err(e) => Err(content::GetError::OpenStream(e)), - } - }) - .await; - close_last(last, id).await; - result -} - -/// Fetches and verifies the content addressed by `mcid` from whichever -/// station a signed `content_announcement` names as its host, dialing -/// that station in one hop instead of relaying through `resolve_via`'s own -/// station. Mirrors `macula_direct_dial:get_content/3`. -/// -/// `timeout` bounds the whole fetch: finding providers, each dial and each -/// transfer. A provider whose dial or transfer fails, including content -/// that doesn't verify against `mcid`, is skipped for the next one. When -/// the timeout cuts a transfer off, [`GetDirectError::Timeout`] carries the -/// previous failure, and a session dialed for that transfer is dropped -/// without a GOODBYE. A provider whose station this process already has a -/// session open to under `id` is fetched from on that session, which stays -/// open. -/// -/// **Architectural note this module's other direct-dial functions don't -/// need**: a `content_announcement`'s `endpoint` is the FINAL dial target -/// directly (see `macula_record:read_content_announcement/1`'s `endpoint` -/// field and `macula:get_content_station/5`'s use of it as-is) — unlike -/// `procedure_advertisement`, there is no station-relay indirection, so -/// the announcer must genuinely BE independently dialable there. A plain -/// outbound-only leaf (everything this SDK's own identity/session model -/// supports) cannot legitimately publish one of these about itself — only -/// something with its own listening identity (`macula-station`, or a -/// dedicated content-serving relay) can; confirmed directly against -/// `macula.erl`, which states a `content_announcement` is made -/// "automatically by the station on receipt," not by an arbitrary -/// publisher. This crate therefore does not expose a client-facing -/// "announce content direct": [`dht::new_content_announcement`] stays a -/// low-level primitive (mirroring `macula_record.erl`'s own export) for -/// that kind of infrastructure-tier code, not ordinary leaf use. -/// [`get_direct`] itself has no such limitation — resolving and fetching -/// FROM an already-announced provider is a perfectly ordinary leaf -/// operation. -pub async fn get_direct( - resolve_via: &Session, - id: &KeyPair, - mcid: Mcid, - timeout: Duration, -) -> Result, GetDirectError> { - fetch_content( - &mut Via { - session: resolve_via, - id, - }, - mcid, - move |station: &[u8; 32]| open_session_to(id, station), - move |resolved: Resolved, share: Duration| dial_target(resolved, id, share), - move |target: StationTarget, _remaining: Duration| get_then_release(target, id, mcid), - timeout, - ) - .await - .map_err(get_direct_failure) -} - -/// Every content announcement that verifies, in DHT order. Mirrors -/// `macula.erl`'s `decode_provider/1`: the record's OWN signature must -/// verify, AND the payload's claimed `announcer_node` must equal the -/// record's own envelope key — a record merely stored under the right key -/// but self-signed by a different identity would otherwise still be -/// trusted. -fn trusted_content_providers(recs: &[Record]) -> Vec { - recs.iter() - .filter_map(|rec| { - dht::verify(rec).ok()?; - let adv = dht::read_content_announcement(rec).ok()?; - (adv.announcer_node == rec.key).then_some(ContentCandidate { - announcer: adv.announcer_node, - version: rec.version, - endpoint: adv.endpoint, - }) - }) - .collect() -} - -/// Splits a `content_announcement`'s `endpoint` (a dialable seed URL, e.g. -/// `"https://host:4433"` — `macula_client:seed()`'s own format) into the -/// host/port pair [`connection::connect`] wants. Distinct from -/// `station_endpoint`'s already-split `host_advertised`/`quic_port` -/// fields — `content_announcement` embeds a single ready-to-dial URL -/// instead. Tolerates a bare `host:port` with no scheme too, matching this -/// crate's own tolerance elsewhere for a station config given without one. -fn parse_seed_url(seed: &str) -> Option<(String, u16)> { - if let Some(rest) = seed - .strip_prefix("https://") - .or_else(|| seed.strip_prefix("http://")) - { - let hostport = rest.split('/').next().unwrap_or(rest); - let (host, port_str) = hostport.rsplit_once(':')?; - return Some((host.to_string(), port_str.parse().ok()?)); - } - let (host, port_str) = seed.rsplit_once(':')?; - Some((host.to_string(), port_str.parse().ok()?)) -} - -/// Direct-dial candidate selection with no network: a fake DHT answers with -/// real signed records, and fake dials and requests remember which stations -/// were reached. The cases and names match `macula-go`'s -/// `directdial/candidates_test.go` and `macula-dotnet`'s -/// `DirectDialCandidatesTests`, so every SDK in the family is held to the -/// same behaviour. -#[cfg(test)] -mod tests { - use std::cell::Cell; - use std::collections::{HashMap, HashSet}; - use std::sync::{Arc, Mutex}; - use std::time::Instant; - - use super::*; - use crate::cert_chain::fixtures::{pem_bundle, test_ca, test_leaf}; - - const REALM: [u8; 32] = [0; 32]; - const PROCEDURE: &str = "macula_rust.candidates_test.echo"; - const ORG: &str = "acme-corp"; - /// Leaves time for every candidate. - const ROOMY: Duration = Duration::from_secs(3); - /// The deadline of the timeout-bound cases. - const SHORT: Duration = Duration::from_millis(300); - /// How long the timeout-bound cases may take to return. - const SHORT_BOUND: Duration = Duration::from_secs(1); - const CONTENT: &[u8] = b"the content"; - - // The fakes' request failures are strings, which count as sent. - impl NotSent for String { - fn not_sent(&self) -> bool { - false - } - } - - fn procedure_key() -> [u8; 32] { - dht::procedure_key(&dht::discovery_uri(REALM, PROCEDURE)) - } - - /// A provider: its own advertisement signer (the DHT keeps one record - /// per signer) and the station serving it at `host`. - struct Provider { - host: String, - advertiser: KeyPair, - station: KeyPair, - } - - impl Provider { - fn new(host: &str) -> Self { - Self { - host: host.to_string(), - advertiser: KeyPair::generate(), - station: KeyPair::generate(), - } - } - } - - fn advertisement(p: &Provider) -> Record { - advertisement_expiring_in(p, 120_000) - } - - fn advertisement_expiring_in(p: &Provider, expires_in_ms: i128) -> Record { - let mut rec = dht::new_procedure_advertisement( - p.advertiser.node_id(), - dht::discovery_uri(REALM, PROCEDURE), - p.station.node_id(), - Duration::from_secs(120), - ); - rec.expires_at = now_ms() + expires_in_ms; - dht::sign(rec, &p.advertiser) - } - - fn authorized_advertisement( - p: &Provider, - ca: &rcgen::Issuer<'static, rcgen::KeyPair>, - org: &str, - ) -> Record { - let leaf = test_leaf( - ca, - p.advertiser.node_id(), - org, - time::OffsetDateTime::now_utc() + time::Duration::hours(1), - ); - let rec = dht::new_procedure_advertisement_with_cert_chain( - p.advertiser.node_id(), - dht::discovery_uri(REALM, PROCEDURE), - p.station.node_id(), - Duration::from_secs(120), - pem_bundle(&[leaf]), - ); - dht::sign(rec, &p.advertiser) - } - - /// A `station_endpoint` advertising `host`, signed by `signer` — - /// normally the station itself. Each call is a new record version. - fn station_endpoint(signer: &KeyPair, host: &str) -> Record { - let created_at = now_ms(); - dht::sign( - Record { - record_type: dht::TYPE_STATION_ENDPOINT, - key: signer.node_id(), - version: *uuid::Uuid::now_v7().as_bytes(), - created_at, - expires_at: created_at + 600_000, - payload: Value::Map(vec![ - (Value::text("quic_port"), Value::Int(4433)), - ( - Value::text("host_advertised"), - Value::List(vec![Value::Bytes(host.as_bytes().to_vec())]), - ), - ]), - signature: Vec::new(), - }, - signer, - ) - } - - /// A `station_endpoint` signed by `signer` that advertises no host, so it - /// names no dialable address. - fn malformed_station_endpoint(signer: &KeyPair) -> Record { - let created_at = now_ms(); - dht::sign( - Record { - record_type: dht::TYPE_STATION_ENDPOINT, - key: signer.node_id(), - version: *uuid::Uuid::now_v7().as_bytes(), - created_at, - expires_at: created_at + 600_000, - payload: Value::Map(vec![ - (Value::text("quic_port"), Value::Int(4433)), - (Value::text("host_advertised"), Value::List(vec![])), - ]), - signature: Vec::new(), - }, - signer, - ) - } - - fn announcement(p: &Provider, mcid: Mcid) -> Record { - dht::sign( - dht::new_content_announcement( - p.station.node_id(), - mcid, - format!("https://{}:4433", p.host), - Duration::from_secs(120), - ), - &p.station, - ) - } - - fn new_mcid() -> Mcid { - std::array::from_fn(|_| rand::random()) - } - - /// This process has no session open to any station. - fn nothing_open(_station: &[u8; 32]) -> Option { - None - } - - #[tokio::test] - async fn call_reports_the_last_candidate_failure_when_a_later_pass_finds_none() { - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)], vec![]]); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - let stations = FakeStations::default(); - stations.refuse(&a.host); - - let result = call(&dht, &stations, Duration::from_secs(1)).await; - - assert!( - matches!(&result, Err(Failure::Dial(e)) if e.contains("refused")), - "{result:?}" - ); - } - - #[tokio::test] - async fn call_reports_the_last_candidate_failure_when_a_later_lookup_fails() { - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - dht.fail_lookups_after(procedure_key(), 1); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - let stations = FakeStations::default(); - stations.refuse(&a.host); - - let result = call(&dht, &stations, Duration::from_secs(1)).await; - - assert!( - matches!(&result, Err(Failure::Dial(e)) if e.contains("refused")), - "{result:?}" - ); - } - - #[tokio::test] - async fn get_direct_reports_the_last_candidate_failure_when_a_later_lookup_fails() { - let p = Provider::new("p.test"); - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.answer(dht::content_key(mcid), vec![vec![announcement(&p, mcid)]]); - dht.fail_lookups_after(dht::content_key(mcid), 1); - let stations = FakeStations::default(); - stations.refuse(&p.host); - - let result = fetch_content( - &mut dht.clone(), - mcid, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |_host: String, _remaining: Duration| ready(Ok::<_, String>(CONTENT.to_vec())), - Duration::from_secs(1), - ) - .await; - - assert!( - matches!(&result, Err(ContentFailure::Dial(e)) if e.contains("refused")), - "{result:?}" - ); - } - - #[tokio::test] - async fn call_retries_after_a_lookup_fails() { - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - dht.fail_first_lookups(procedure_key(), 1); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - let stations = FakeStations::default(); - - let reply = call(&dht, &stations, ROOMY) - .await - .expect("a answers once a lookup is answered"); - - assert_eq!(reply, "reply from a.test"); - assert_eq!(dht.asked_at(procedure_key()).len(), 2); - } - - #[tokio::test] - async fn get_direct_retries_after_a_lookup_fails() { - let p = Provider::new("p.test"); - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.answer(dht::content_key(mcid), vec![vec![announcement(&p, mcid)]]); - dht.fail_first_lookups(dht::content_key(mcid), 1); - let stations = FakeStations::default(); - - let content = fetch_content( - &mut dht.clone(), - mcid, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |_host: String, _remaining: Duration| ready(Ok::<_, String>(CONTENT.to_vec())), - ROOMY, - ) - .await - .expect("p serves the content once a lookup is answered"); - - assert_eq!(content, CONTENT); - assert_eq!(stations.reached(), ["p.test"]); - } - - #[tokio::test] - async fn call_reports_a_failed_lookup_at_its_deadline_when_no_candidate_was_tried() { - let dht = FakeDht::new(); - dht.fail_first_lookups(procedure_key(), usize::MAX); - let started = Instant::now(); - - let result = call(&dht, &FakeStations::default(), SHORT).await; - - assert!( - matches!(result, Err(Failure::Resolve(ResolveError::Dht(_)))), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - assert!( - dht.asked_at(procedure_key()).len() > 1, - "a failed lookup is retried until the deadline" - ); - } - - #[tokio::test] - async fn get_direct_reports_a_failed_lookup_at_its_deadline_when_no_provider_was_tried() { - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.fail_first_lookups(dht::content_key(mcid), usize::MAX); - let stations = FakeStations::default(); - let started = Instant::now(); - - let result = fetch_content( - &mut dht.clone(), - mcid, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |_host: String, _remaining: Duration| ready(Ok::<_, String>(CONTENT.to_vec())), - SHORT, - ) - .await; - - assert!(matches!(result, Err(ContentFailure::Dht(_))), "{result:?}"); - assert_returned_within(SHORT_BOUND, started); - assert!( - dht.asked_at(dht::content_key(mcid)).len() > 1, - "a failed lookup is retried until the deadline" - ); - } - - #[tokio::test] - async fn call_reports_not_advertised_when_a_lookup_answered_before_later_ones_failed() { - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![]]); - dht.fail_lookups_after(procedure_key(), 1); - - let result = call(&dht, &FakeStations::default(), SHORT).await; - - assert!( - matches!( - result, - Err(Failure::Resolve(ResolveError::ProcedureNotAdvertised)) - ), - "{result:?}" - ); - } - - #[tokio::test] - async fn call_reports_a_timeout_when_no_lookup_was_answered_in_time() { - let dht = FakeDht::new(); - dht.never_answer(procedure_key()); - let started = Instant::now(); - - let result = call(&dht, &FakeStations::default(), SHORT).await; - - assert!( - matches!(result, Err(Failure::Resolve(ResolveError::Timeout))), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - } - - #[tokio::test] - async fn call_keeps_a_lookup_error_when_a_later_lookup_is_cut_off_by_the_deadline() { - let dht = FakeDht::new(); - dht.fail_first_lookups(procedure_key(), 1); - dht.never_answer(procedure_key()); - - let result = call(&dht, &FakeStations::default(), SHORT).await; - - assert!( - matches!(result, Err(Failure::Resolve(ResolveError::Dht(_)))), - "{result:?}" - ); - } - - #[tokio::test] - async fn get_direct_reports_not_announced_when_a_lookup_answered_before_later_ones_failed() { - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.answer(dht::content_key(mcid), vec![vec![]]); - dht.fail_lookups_after(dht::content_key(mcid), 1); - let stations = FakeStations::default(); - - let result = fetch_content( - &mut dht.clone(), - mcid, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |_host: String, _remaining: Duration| ready(Ok::<_, String>(CONTENT.to_vec())), - SHORT, - ) - .await; - - assert!( - matches!(result, Err(ContentFailure::NotAnnounced)), - "{result:?}" - ); - } - - #[tokio::test] - async fn get_direct_reports_a_timeout_when_no_lookup_was_answered_in_time() { - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.never_answer(dht::content_key(mcid)); - let stations = FakeStations::default(); - let started = Instant::now(); - - let result = fetch_content( - &mut dht.clone(), - mcid, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |_host: String, _remaining: Duration| ready(Ok::<_, String>(CONTENT.to_vec())), - SHORT, - ) - .await; - - assert!( - matches!(result, Err(ContentFailure::Timeout(None))), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - } - - /// A DHT that answers `find_records` on a key with successive replies, - /// repeating the last, and `find_record` with the `station_endpoint` - /// published under that key (not_found otherwise). Clones share state, - /// so a test can publish while a call is running. - #[derive(Clone)] - struct FakeDht(Arc>); - - struct DhtState { - started: Instant, - replies: HashMap<[u8; 32], Vec>>, - asked: HashMap<[u8; 32], Vec>, - fail_after: HashMap<[u8; 32], usize>, - fail_first: HashMap<[u8; 32], usize>, - never_answered: HashSet<[u8; 32]>, - endpoint_asked: HashMap<[u8; 32], usize>, - endpoints: HashMap<[u8; 32], Record>, - endpoints_in_turn: HashMap<[u8; 32], Vec>, - } - - impl FakeDht { - fn new() -> Self { - Self(Arc::new(Mutex::new(DhtState { - started: Instant::now(), - replies: HashMap::new(), - asked: HashMap::new(), - fail_after: HashMap::new(), - fail_first: HashMap::new(), - never_answered: HashSet::new(), - endpoint_asked: HashMap::new(), - endpoints: HashMap::new(), - endpoints_in_turn: HashMap::new(), - }))) - } - - fn answer(&self, key: [u8; 32], replies: Vec>) { - self.0.lock().unwrap().replies.insert(key, replies); - } - - fn publish_endpoint(&self, station: &KeyPair, endpoint: Record) { - self.0 - .lock() - .unwrap() - .endpoints - .insert(dht::station_endpoint_key(station.node_id()), endpoint); - } - - /// `find_record` for `station`'s `station_endpoint` answers with - /// `endpoints` in turn, repeating the last. - fn publish_endpoints_in_turn(&self, station: &KeyPair, endpoints: Vec) { - self.0 - .lock() - .unwrap() - .endpoints_in_turn - .insert(dht::station_endpoint_key(station.node_id()), endpoints); - } - - /// When `find_records` was asked for `key`, since this DHT was - /// created. - fn asked_at(&self, key: [u8; 32]) -> Vec { - self.0 - .lock() - .unwrap() - .asked - .get(&key) - .cloned() - .unwrap_or_default() - } - - /// After `answered_lookups` lookups, `find_records` on `key` fails, - /// the way a query over a resolver session that has dropped does. - fn fail_lookups_after(&self, key: [u8; 32], answered_lookups: usize) { - self.0 - .lock() - .unwrap() - .fail_after - .insert(key, answered_lookups); - } - - /// The first `failed_lookups` lookups of `key` fail, the way a query - /// the station doesn't answer in time does; later ones are answered. - fn fail_first_lookups(&self, key: [u8; 32], failed_lookups: usize) { - self.0 - .lock() - .unwrap() - .fail_first - .insert(key, failed_lookups); - } - - /// Lookups of `key` never get an answer once any - /// `fail_first_lookups` have failed; only the caller's deadline ends - /// them. - fn never_answer(&self, key: [u8; 32]) { - self.0.lock().unwrap().never_answered.insert(key); - } - - /// Answers one `find_records` lookup of `key` at once, or `None` when - /// `key` is never answered. - fn lookup_now(&self, key: [u8; 32]) -> Option, DhtError>> { - let mut state = self.0.lock().unwrap(); - let elapsed = state.started.elapsed(); - let asked = state.asked.entry(key).or_default(); - asked.push(elapsed); - let turn = asked.len() - 1; - if state - .fail_first - .get(&key) - .is_some_and(|failed| turn < *failed) - { - return Some(Err(DhtError::Remote( - "the station did not answer the lookup".to_string(), - ))); - } - if state.never_answered.contains(&key) { - return None; - } - if state - .fail_after - .get(&key) - .is_some_and(|answered| turn >= *answered) - { - return Some(Err(DhtError::Remote( - "the resolver session is gone".to_string(), - ))); - } - Some(Ok(state - .replies - .get(&key) - .map(|replies| replies[turn.min(replies.len() - 1)].clone()) - .unwrap_or_default())) - } - - /// How many times `find_record` was asked for `station`'s - /// `station_endpoint`. - fn endpoint_lookups_of(&self, station: &KeyPair) -> usize { - self.0 - .lock() - .unwrap() - .endpoint_asked - .get(&dht::station_endpoint_key(station.node_id())) - .copied() - .unwrap_or(0) - } - - /// Answers one `find_record` lookup of `key` at once, or `None` when - /// `key` is never answered. `fail_first_lookups` and `never_answer` - /// apply to `station_endpoint` keys too. - fn endpoint_lookup_now(&self, key: [u8; 32]) -> Option> { - let mut state = self.0.lock().unwrap(); - let asked = state.endpoint_asked.entry(key).or_default(); - *asked += 1; - let turn = *asked - 1; - if state - .fail_first - .get(&key) - .is_some_and(|failed| turn < *failed) - { - return Some(Err(DhtError::Remote( - "the station did not answer the lookup".to_string(), - ))); - } - if state.never_answered.contains(&key) { - return None; - } - if let Some(in_turn) = state.endpoints_in_turn.get(&key) { - return Some(Ok(in_turn[turn.min(in_turn.len() - 1)].clone())); - } - Some(state.endpoints.get(&key).cloned().ok_or(DhtError::NotFound)) - } - } - - impl DhtLookups for FakeDht { - async fn find_records(&mut self, key: [u8; 32]) -> Result, DhtError> { - match self.lookup_now(key) { - Some(answer) => answer, - None => std::future::pending().await, - } - } - - async fn find_record(&mut self, key: [u8; 32]) -> Result { - match self.endpoint_lookup_now(key) { - Some(answer) => answer, - None => std::future::pending().await, - } - } - } - - /// Fake dials: remembers every host it is asked to reach, in order, and - /// refuses the hosts marked as refusing before anything is sent. - #[derive(Clone, Default)] - struct FakeStations(Arc>); - - #[derive(Default)] - struct StationsState { - reached: Vec, - refusing: HashSet, - } - - impl FakeStations { - fn refuse(&self, host: &str) { - self.0.lock().unwrap().refusing.insert(host.to_string()); - } - - fn reached(&self) -> Vec { - self.0.lock().unwrap().reached.clone() - } - - fn dial(&self, station: &Resolved) -> Result { - let mut state = self.0.lock().unwrap(); - state.reached.push(station.host.clone()); - if state.refusing.contains(&station.host) { - Err(format!("{} refused the connection", station.host)) - } else { - Ok(station.host.clone()) - } - } - } - - async fn call( - dht: &FakeDht, - stations: &FakeStations, - timeout: Duration, - ) -> Result> { - reach_procedure( - &mut dht.clone(), - REALM, - PROCEDURE, - None, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |host: String, _remaining: Duration| ready(Ok(format!("reply from {host}"))), - timeout, - ) - .await - } - - async fn call_with_cert_chain( - dht: &FakeDht, - stations: &FakeStations, - realm_ca_pem: &[u8], - timeout: Duration, - ) -> Result> { - reach_procedure( - &mut dht.clone(), - REALM, - PROCEDURE, - Some(CertChainCheck { - realm_ca_pem, - expected_org: ORG, - }), - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |host: String, _remaining: Duration| ready(Ok(format!("reply from {host}"))), - timeout, - ) - .await - } - - fn two_providers_with_endpoints() -> (FakeDht, Provider, Provider) { - let (a, b) = (Provider::new("a.test"), Provider::new("b.test")); - let dht = FakeDht::new(); - dht.answer( - procedure_key(), - vec![vec![advertisement(&a), advertisement(&b)]], - ); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - dht.publish_endpoint(&b.station, station_endpoint(&b.station, &b.host)); - (dht, a, b) - } - - fn assert_returned_within(bound: Duration, started: Instant) { - let took = started.elapsed(); - assert!( - took < bound, - "expected to return within {bound:?}, took {took:?}" - ); - } - - #[tokio::test] - async fn call_tries_the_next_advertisement_when_a_station_has_no_endpoint() { - let (a, b) = (Provider::new("a.test"), Provider::new("b.test")); - let dht = FakeDht::new(); - dht.answer( - procedure_key(), - vec![vec![advertisement(&a), advertisement(&b)]], - ); - dht.publish_endpoint(&b.station, station_endpoint(&b.station, &b.host)); - let stations = FakeStations::default(); - - let reply = call(&dht, &stations, ROOMY).await.expect("b answers"); - - assert_eq!(reply, "reply from b.test"); - assert_eq!(stations.reached(), ["b.test"]); - } - - #[tokio::test] - async fn call_retries_when_no_advertisement_qualifies() { - let (a, b) = (Provider::new("a.test"), Provider::new("b.test")); - let dht = FakeDht::new(); - dht.answer( - procedure_key(), - vec![ - vec![advertisement_expiring_in(&a, -1_000)], - vec![advertisement(&b)], - ], - ); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - dht.publish_endpoint(&b.station, station_endpoint(&b.station, &b.host)); - let stations = FakeStations::default(); - - let reply = call(&dht, &stations, ROOMY).await.expect("b answers"); - - assert_eq!(reply, "reply from b.test"); - assert_eq!(stations.reached(), ["b.test"]); - } - - #[tokio::test] - async fn call_tries_the_next_station_when_a_dial_fails() { - let (dht, a, _b) = two_providers_with_endpoints(); - let stations = FakeStations::default(); - stations.refuse(&a.host); - - let reply = call(&dht, &stations, ROOMY).await.expect("b answers"); - - assert_eq!(reply, "reply from b.test"); - assert_eq!(stations.reached(), ["a.test", "b.test"]); - } - - /// A guard, green before and after the fix: once the CALL has gone out, - /// its failure is the call's result. - #[tokio::test] - async fn call_never_sends_the_request_twice() { - let (dht, _a, _b) = two_providers_with_endpoints(); - let stations = FakeStations::default(); - - let result = reach_procedure( - &mut dht.clone(), - REALM, - PROCEDURE, - None, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |host: String, _remaining: Duration| { - ready(Err::(format!( - "{host} reset the stream after the CALL went out" - ))) - }, - ROOMY, - ) - .await; - - assert!( - matches!(&result, Err(Failure::Request(e)) if e.starts_with("a.test")), - "{result:?}" - ); - assert_eq!(stations.reached(), ["a.test"]); - } - - #[tokio::test] - async fn call_timeout_bounds_resolution() { - let started = Instant::now(); - - let result = call(&FakeDht::new(), &FakeStations::default(), SHORT).await; - - assert!( - matches!( - result, - Err(Failure::Resolve(ResolveError::ProcedureNotAdvertised)) - ), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - } - - #[tokio::test] - async fn call_timeout_bounds_the_endpoint_lookup() { - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - let stations = FakeStations::default(); - let started = Instant::now(); - - let result = call(&dht, &stations, SHORT).await; - - assert!( - matches!( - result, - Err(Failure::Resolve(ResolveError::StationEndpointNotFound)) - ), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - assert!(stations.reached().is_empty()); - } - - #[tokio::test] - async fn open_stream_direct_tries_the_next_station_when_a_dial_fails() { - let (dht, a, _b) = two_providers_with_endpoints(); - let stations = FakeStations::default(); - stations.refuse(&a.host); - - let stream = reach_procedure( - &mut dht.clone(), - REALM, - PROCEDURE, - None, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |host: String, _remaining: Duration| { - ready(Ok::<_, String>(format!("stream at {host}"))) - }, - ROOMY, - ) - .await - .expect("b opens"); - - assert_eq!(stream, "stream at b.test"); - assert_eq!(stations.reached(), ["a.test", "b.test"]); - } - - /// A guard, green before and after the fix: STREAM_OPEN may already be - /// out once the station is dialed, so a failure there is never retried. - #[tokio::test] - async fn open_stream_direct_never_opens_the_stream_twice() { - let (dht, _a, _b) = two_providers_with_endpoints(); - let stations = FakeStations::default(); - - let result = reach_procedure( - &mut dht.clone(), - REALM, - PROCEDURE, - None, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |host: String, _remaining: Duration| { - ready(Err::(format!( - "{host} failed after STREAM_OPEN may have gone out" - ))) - }, - ROOMY, - ) - .await; - - assert!( - matches!(&result, Err(Failure::Request(e)) if e.starts_with("a.test")), - "{result:?}" - ); - assert_eq!(stations.reached(), ["a.test"]); - } - - #[tokio::test] - async fn get_direct_retries_when_no_provider_qualifies() { - let p = Provider::new("p.test"); - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.answer( - dht::content_key(mcid), - vec![vec![], vec![announcement(&p, mcid)]], - ); - let stations = FakeStations::default(); - - let content = fetch_content( - &mut dht.clone(), - mcid, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |_host: String, _remaining: Duration| ready(Ok::<_, String>(CONTENT.to_vec())), - ROOMY, - ) - .await - .expect("p serves the content"); - - assert_eq!(content, CONTENT); - assert_eq!(stations.reached(), ["p.test"]); - } - - #[tokio::test] - async fn get_direct_tries_the_next_provider_after_a_failed_fetch() { - let (a, b) = (Provider::new("a.test"), Provider::new("b.test")); - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.answer( - dht::content_key(mcid), - vec![vec![announcement(&a, mcid), announcement(&b, mcid)]], - ); - let stations = FakeStations::default(); - - let content = fetch_content( - &mut dht.clone(), - mcid, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |host: String, _remaining: Duration| { - ready(if host == "a.test" { - Err("fetched content does not hash to its MCID".to_string()) - } else { - Ok(CONTENT.to_vec()) - }) - }, - ROOMY, - ) - .await - .expect("b serves the content"); - - assert_eq!(content, CONTENT); - assert_eq!(stations.reached(), ["a.test", "b.test"]); - } - - #[tokio::test] - async fn get_direct_timeout_bounds_resolution() { - let stations = FakeStations::default(); - let started = Instant::now(); - - let result = fetch_content( - &mut FakeDht::new(), - new_mcid(), - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |_host: String, _remaining: Duration| ready(Ok::<_, String>(CONTENT.to_vec())), - SHORT, - ) - .await; - - assert!( - matches!(result, Err(ContentFailure::NotAnnounced)), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - } - - async fn put( - dht: &FakeDht, - stations: &FakeStations, - station: &KeyPair, - timeout: Duration, - ) -> Result> { - reach_station( - &mut dht.clone(), - station.node_id(), - nothing_open, - |r: Resolved, _remaining: Duration| ready(stations.dial(&r)), - |host: String, _remaining: Duration| { - ready(Ok::<_, String>(format!("stored on {host}"))) - }, - timeout, - ) - .await - } - - #[tokio::test] - async fn put_direct_reports_no_station_endpoint_when_a_lookup_answered_not_found() { - let result = put( - &FakeDht::new(), - &FakeStations::default(), - &KeyPair::generate(), - SHORT, - ) - .await; - - assert!( - matches!( - result, - Err(Failure::Resolve(ResolveError::StationEndpointNotFound)) - ), - "{result:?}" - ); - } - - #[tokio::test] - async fn put_direct_retries_an_endpoint_lookup_that_fails() { - let station = KeyPair::generate(); - let dht = FakeDht::new(); - dht.publish_endpoint(&station, station_endpoint(&station, "s.test")); - dht.fail_first_lookups(dht::station_endpoint_key(station.node_id()), 1); - - let stored = put(&dht, &FakeStations::default(), &station, ROOMY) - .await - .expect("s stores once its endpoint lookup is answered"); - - assert_eq!(stored, "stored on s.test"); - } - - #[tokio::test] - async fn put_direct_reports_a_failed_endpoint_lookup_when_every_lookup_failed() { - let station = KeyPair::generate(); - let dht = FakeDht::new(); - dht.fail_first_lookups(dht::station_endpoint_key(station.node_id()), usize::MAX); - let started = Instant::now(); - - let result = put(&dht, &FakeStations::default(), &station, SHORT).await; - - assert!( - matches!(result, Err(Failure::Resolve(ResolveError::Dht(_)))), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - assert!( - dht.endpoint_lookups_of(&station) > 1, - "a failed endpoint lookup is retried within the budget" - ); - } - - #[tokio::test] - async fn put_direct_reports_a_timeout_when_no_endpoint_lookup_was_answered_in_time() { - let station = KeyPair::generate(); - let dht = FakeDht::new(); - dht.never_answer(dht::station_endpoint_key(station.node_id())); - let started = Instant::now(); - - let result = put(&dht, &FakeStations::default(), &station, SHORT).await; - - assert!( - matches!(result, Err(Failure::Resolve(ResolveError::Timeout))), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - } - - #[tokio::test] - async fn put_direct_keeps_a_lookup_error_when_a_later_endpoint_lookup_is_cut_off_by_the_deadline( - ) { - let station = KeyPair::generate(); - let dht = FakeDht::new(); - dht.fail_first_lookups(dht::station_endpoint_key(station.node_id()), 1); - dht.never_answer(dht::station_endpoint_key(station.node_id())); - - let result = put(&dht, &FakeStations::default(), &station, SHORT).await; - - assert!( - matches!(result, Err(Failure::Resolve(ResolveError::Dht(_)))), - "{result:?}" - ); - } - - #[tokio::test] - async fn put_direct_asks_again_past_a_malformed_endpoint_record() { - let station = KeyPair::generate(); - let dht = FakeDht::new(); - dht.publish_endpoints_in_turn( - &station, - vec![ - malformed_station_endpoint(&station), - station_endpoint(&station, "s.test"), - ], - ); - - let stored = put(&dht, &FakeStations::default(), &station, ROOMY) - .await - .expect("s stores once a lookup finds its good endpoint record"); - - assert_eq!(stored, "stored on s.test"); - } - - #[tokio::test] - async fn put_direct_reports_a_malformed_endpoint_record_at_its_deadline() { - let station = KeyPair::generate(); - let dht = FakeDht::new(); - dht.publish_endpoint(&station, malformed_station_endpoint(&station)); - let started = Instant::now(); - - let result = put(&dht, &FakeStations::default(), &station, SHORT).await; - - assert!( - matches!( - result, - Err(Failure::Resolve(ResolveError::MalformedStationEndpoint)) - ), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - assert!( - dht.endpoint_lookups_of(&station) > 1, - "a malformed endpoint record is asked again within the budget" - ); - } - - #[tokio::test] - async fn call_reports_a_timeout_when_no_endpoint_lookup_was_answered_in_time() { - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - dht.never_answer(dht::station_endpoint_key(a.station.node_id())); - let stations = FakeStations::default(); - let started = Instant::now(); - - let result = call(&dht, &stations, SHORT).await; - - assert!( - matches!(result, Err(Failure::Resolve(ResolveError::Timeout))), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - assert!(stations.reached().is_empty()); - } - - #[tokio::test] - async fn put_direct_timeout_bounds_the_endpoint_lookup() { - let station = KeyPair::generate(); - let stations = FakeStations::default(); - let started = Instant::now(); - - let result = reach_station( - &mut FakeDht::new(), - station.node_id(), - nothing_open, - |r: Resolved, _remaining: Duration| ready(stations.dial(&r)), - |host: String, _remaining: Duration| ready(Ok::<_, String>(host)), - SHORT, - ) - .await; - - assert!( - matches!( - result, - Err(Failure::Resolve(ResolveError::StationEndpointNotFound)) - ), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - assert!(stations.reached().is_empty()); - } - - #[tokio::test] - async fn open_stream_direct_reuses_an_open_session_to_the_provider_station() { - let a = Provider::new("a.test"); - let a_station = a.station.node_id(); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - let stations = FakeStations::default(); - - let stream = reach_procedure( - &mut dht.clone(), - REALM, - PROCEDURE, - None, - |station: &[u8; 32]| { - (*station == a_station).then(|| "the caller's session to a".to_string()) - }, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |session: String, _remaining: Duration| { - ready(Ok::<_, String>(format!("stream on {session}"))) - }, - Duration::from_secs(1), - ) - .await - .expect("the stream opens on the open session"); - - assert_eq!(stream, "stream on the caller's session to a"); - assert!(stations.reached().is_empty()); - } - - #[tokio::test] - async fn get_direct_reuses_an_open_session_to_the_provider_station() { - let p = Provider::new("p.test"); - let p_station = p.station.node_id(); - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.answer(dht::content_key(mcid), vec![vec![announcement(&p, mcid)]]); - let stations = FakeStations::default(); - stations.refuse(&p.host); - - let content = fetch_content( - &mut dht.clone(), - mcid, - |station: &[u8; 32]| { - (*station == p_station).then(|| "the caller's session to p".to_string()) - }, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |session: String, _remaining: Duration| { - ready(if session == "the caller's session to p" { - Ok(CONTENT.to_vec()) - } else { - Err(format!("{session} does not serve the content")) - }) - }, - Duration::from_secs(1), - ) - .await - .expect("p serves the content on the open session"); - - assert_eq!(content, CONTENT); - assert!(stations.reached().is_empty()); - } - - #[tokio::test] - async fn put_direct_reuses_an_open_session_to_the_station() { - let station = KeyPair::generate().node_id(); - let stations = FakeStations::default(); - - let stored = reach_station( - &mut FakeDht::new(), - station, - |open: &[u8; 32]| { - (*open == station).then(|| "the caller's session to the station".to_string()) - }, - |r: Resolved, _remaining: Duration| ready(stations.dial(&r)), - |session: String, _remaining: Duration| { - ready(Ok::<_, String>(format!("stored on {session}"))) - }, - SHORT, - ) - .await - .expect("stored on the open session"); - - assert_eq!(stored, "stored on the caller's session to the station"); - assert!(stations.reached().is_empty()); - } - - // Sharing a session direct dial dialed between the requests that reuse - // it. The names match the .NET and Go tests. - - #[derive(Clone)] - struct FakeSession { - leases: Option>, - } - - impl Leased for FakeSession { - fn leases(&self) -> Option<&Leases> { - self.leases.as_deref() - } - } - - fn dialed_session() -> FakeSession { - FakeSession { - leases: Some(std::sync::Arc::new(Leases::new())), - } - } - - #[tokio::test] - async fn a_dialed_session_is_closed_after_the_request_even_when_it_fails() { - let target = StationTarget::dialed(dialed_session()); - - let (result, closed) = run_then_release(target, |_session| { - ready(Err::<(), _>("reset after the request went out")) - }) - .await; - - assert!(result.is_err()); - assert!(closed.is_some(), "the dialed session is closed"); - } - - #[tokio::test] - async fn a_reused_session_is_never_closed_by_the_request() { - let owned = FakeSession { leases: None }; - let target = StationTarget::reuse(owned).expect("an owner's open session is reused"); - - let (_, closed) = run_then_release(target, |_session| ready("answered")).await; - - assert!(closed.is_none()); - } - - #[test] - fn a_dialed_session_stays_open_until_its_last_lease_is_released() { - let leases = Leases::new(); - assert!(leases.try_lease()); - - assert!(!leases.release(), "one lease is still out"); - assert!(leases.release(), "the last lease closes the session"); - } - - #[test] - fn a_session_that_is_closing_is_not_reused() { - let session = dialed_session(); - assert!(StationTarget::dialed(session.clone()) - .release_lease() - .is_some()); - - assert!(StationTarget::reuse(session).is_none()); - } - - #[tokio::test] - async fn two_concurrent_transfers_to_one_station_share_the_dialed_session_until_both_finish() { - let session = dialed_session(); - let first = StationTarget::dialed(session.clone()); - let second = StationTarget::reuse(session).expect("the dialed session is reused"); - - let (_, closed) = run_then_release(first, |_session| ready("first stored")).await; - assert!( - closed.is_none(), - "the second transfer still uses the session" - ); - - let (_, closed) = run_then_release(second, |_session| ready("second stored")).await; - assert!(closed.is_some(), "the last transfer closes the session"); - } - - #[tokio::test] - async fn a_dialed_session_shared_by_a_stream_and_a_call_closes_only_when_both_release() { - let session = dialed_session(); - let stream = StationTarget::dialed(session.clone()); - let call = StationTarget::reuse(session).expect("the dialed session is reused"); - - let (_, closed) = run_then_release(call, |_session| ready("answered")).await; - assert!(closed.is_none(), "the stream still uses the session"); - - assert!(stream.release_lease().is_some()); - } - - // A direct call reuses an open session, and after its request failed goes - // on to the next candidate only when its CALL was never sent. The names - // match the .NET and Go tests. - - /// A call on the fakes whose request is `request`. - async fn call_requesting( - dht: &FakeDht, - stations: &FakeStations, - already_open: impl FnMut(&[u8; 32]) -> Option, - request: impl FnMut(String, Duration) -> RF, - ) -> Result> - where - RF: Future>, - { - reach_procedure( - &mut dht.clone(), - REALM, - PROCEDURE, - None, - already_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - request, - ROOMY, - ) - .await - } - - fn answer_from(session: String) -> std::future::Ready> { - ready(Ok(format!("reply from {session}"))) - } - - #[tokio::test] - async fn call_reuses_an_open_session_to_the_provider_station() { - // No endpoint is published for a, so only reuse can reach it. - let a = Provider::new("a.test"); - let a_station = a.station.node_id(); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - let stations = FakeStations::default(); - - let reply = call_requesting( - &dht, - &stations, - |station: &[u8; 32]| { - (*station == a_station).then(|| "the caller's session to a".to_string()) - }, - |session: String, _remaining: Duration| answer_from(session), - ) - .await - .expect("the call is answered on the open session"); - - assert_eq!(reply, "reply from the caller's session to a"); - assert!(stations.reached().is_empty()); - } - - #[tokio::test] - async fn a_direct_call_whose_reused_session_has_ended_dials_the_station_fresh() { - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - let stations = FakeStations::default(); - // The caller's session to a is open until the call finds it has ended, - // and a session that ends leaves the open set. - let open = Cell::new(true); - - let reply = call_requesting( - &dht, - &stations, - |_station: &[u8; 32]| open.get().then(|| "the caller's session to a".to_string()), - |session: String, _remaining: Duration| { - if session != "the caller's session to a" { - return answer_from(session); - } - open.set(false); - ready(Err(connection::CallError::SessionEnded { - reason: connection::SessionEndReason::StreamFailed( - "the station went away".to_string(), - ), - write_started: false, - })) - }, - ) - .await - .expect("the call is answered on a fresh session"); - - assert_eq!(reply, format!("reply from {}", a.host)); - assert_eq!(stations.reached(), vec![a.host.clone()]); - } - - #[tokio::test] - async fn a_direct_call_that_was_not_sent_is_tried_again_on_the_next_pass() { - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - let stations = FakeStations::default(); - let attempts = Cell::new(0); - - let reply = call_requesting( - &dht, - &stations, - nothing_open, - |host: String, _remaining: Duration| { - attempts.set(attempts.get() + 1); - if attempts.get() == 1 { - ready(Err(connection::CallError::Timeout { - write_started: false, - })) - } else { - answer_from(host) - } - }, - ) - .await - .expect("the next pass answers"); - - assert_eq!(reply, format!("reply from {}", a.host)); - assert_eq!(stations.reached(), vec![a.host.clone(), a.host.clone()]); - } - - #[tokio::test] - async fn a_direct_call_that_timed_out_waiting_for_the_write_lock_may_try_the_next_candidate() { - let (dht, a, b) = two_providers_with_endpoints(); - let stations = FakeStations::default(); - - let reply = call_requesting( - &dht, - &stations, - nothing_open, - |host: String, _remaining: Duration| { - if host == a.host { - ready(Err(connection::CallError::Timeout { - write_started: false, - })) - } else { - answer_from(host) - } - }, - ) - .await - .expect("b answers"); - - assert_eq!(reply, format!("reply from {}", b.host)); - assert_eq!(stations.reached(), vec![a.host.clone(), b.host.clone()]); - } - - #[tokio::test] - async fn a_direct_call_that_timed_out_after_its_write_started_is_not_tried_on_another_candidate( - ) { - let (dht, a, _b) = two_providers_with_endpoints(); - let stations = FakeStations::default(); - - let result = call_requesting( - &dht, - &stations, - nothing_open, - |host: String, _remaining: Duration| { - assert_eq!(host, a.host, "the CALL was sent a second time"); - ready(Err(connection::CallError::Timeout { - write_started: true, - })) - }, - ) - .await; - - assert!( - matches!( - result, - Err(Failure::Request(connection::CallError::Timeout { - write_started: true - })) - ), - "{result:?}" - ); - assert_eq!(stations.reached(), vec![a.host.clone()]); - } - - #[tokio::test] - async fn call_with_cert_chain_tries_the_next_advertisement_when_a_station_has_no_endpoint() { - let (ca_pem, ca) = test_ca(); - let (a, b) = (Provider::new("a.test"), Provider::new("b.test")); - let dht = FakeDht::new(); - dht.answer( - procedure_key(), - vec![vec![ - authorized_advertisement(&a, &ca, ORG), - authorized_advertisement(&b, &ca, ORG), - ]], - ); - dht.publish_endpoint(&b.station, station_endpoint(&b.station, &b.host)); - let stations = FakeStations::default(); - - let reply = call_with_cert_chain(&dht, &stations, &ca_pem, ROOMY) - .await - .expect("b answers"); - - assert_eq!(reply, "reply from b.test"); - assert_eq!(stations.reached(), ["b.test"]); - } - - #[tokio::test] - async fn call_with_cert_chain_reports_the_authorization_failure_at_its_deadline() { - let (ca_pem, ca) = test_ca(); - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer( - procedure_key(), - vec![vec![authorized_advertisement(&a, &ca, "other-org")]], - ); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - let stations = FakeStations::default(); - let started = Instant::now(); - - let result = call_with_cert_chain(&dht, &stations, &ca_pem, SHORT).await; - - assert!( - matches!( - result, - Err(Failure::Resolve(ResolveError::NoAuthorizedAdvertisement(_))) - ), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - assert!(stations.reached().is_empty()); - } - - #[tokio::test] - async fn call_skips_a_station_whose_endpoint_is_signed_by_another_key() { - let (a, b) = (Provider::new("a.test"), Provider::new("b.test")); - let dht = FakeDht::new(); - dht.answer( - procedure_key(), - vec![vec![advertisement(&a), advertisement(&b)]], - ); - dht.publish_endpoint(&a.station, station_endpoint(&KeyPair::generate(), &a.host)); - dht.publish_endpoint(&b.station, station_endpoint(&b.station, &b.host)); - let stations = FakeStations::default(); - - let reply = call(&dht, &stations, ROOMY).await.expect("b answers"); - - assert_eq!(reply, "reply from b.test"); - assert_eq!(stations.reached(), ["b.test"]); - } - - #[tokio::test] - async fn call_dials_a_refusing_station_once_per_endpoint_version() { - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - let stations = FakeStations::default(); - stations.refuse(&a.host); - - let (result, ()) = tokio::join!(call(&dht, &stations, ROOMY), async { - tokio::time::sleep(Duration::from_secs(1)).await; - assert_eq!(stations.reached(), ["a.test"]); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - }); - - assert!( - matches!(&result, Err(Failure::Dial(e)) if e.contains("refused")), - "{result:?}" - ); - assert_eq!(stations.reached(), ["a.test", "a.test"]); - } - - #[tokio::test] - async fn call_tries_an_advertisement_that_appears_on_a_later_pass() { - let (a, b) = (Provider::new("a.test"), Provider::new("b.test")); - let ad_a = advertisement(&a); - let dht = FakeDht::new(); - dht.answer( - procedure_key(), - vec![vec![ad_a.clone()], vec![ad_a, advertisement(&b)]], - ); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - dht.publish_endpoint(&b.station, station_endpoint(&b.station, &b.host)); - let stations = FakeStations::default(); - stations.refuse(&a.host); - - let reply = call(&dht, &stations, ROOMY).await.expect("b answers"); - - assert_eq!(reply, "reply from b.test"); - assert_eq!(stations.reached(), ["a.test", "b.test"]); - } - - #[tokio::test] - async fn resolution_backs_off_between_passes() { - let dht = FakeDht::new(); - - let result = call(&dht, &FakeStations::default(), ROOMY).await; - - assert!( - matches!( - result, - Err(Failure::Resolve(ResolveError::ProcedureNotAdvertised)) - ), - "{result:?}" - ); - let asked = dht.asked_at(procedure_key()); - assert!( - (5..=8).contains(&asked.len()), - "expected 5 to 8 lookups, got {} at {asked:?}", - asked.len() - ); - } - - #[tokio::test] - async fn get_direct_fetches_from_a_failing_provider_once_per_announcement() { - let p = Provider::new("p.test"); - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.answer(dht::content_key(mcid), vec![vec![announcement(&p, mcid)]]); - let stations = FakeStations::default(); - let fetches = Cell::new(0); - - let result = fetch_content( - &mut dht.clone(), - mcid, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |_host: String, _remaining: Duration| { - fetches.set(fetches.get() + 1); - ready(Err::, _>( - "fetched content does not hash to its MCID".to_string(), - )) - }, - Duration::from_secs(2), - ) - .await; - - assert!( - matches!(result, Err(ContentFailure::Fetch(_))), - "{result:?}" - ); - assert_eq!(fetches.get(), 1); - } - - #[tokio::test] - async fn call_picks_up_an_endpoint_record_that_changes_mid_deadline() { - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, "a-old.test")); - let stations = FakeStations::default(); - stations.refuse("a-old.test"); - - let (result, ()) = tokio::join!(call(&dht, &stations, ROOMY), async { - tokio::time::sleep(Duration::from_millis(500)).await; - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - }); - - assert_eq!( - result.expect("a answers at its new endpoint"), - "reply from a.test" - ); - assert_eq!(stations.reached(), ["a-old.test", "a.test"]); - } - - #[tokio::test] - async fn resolve_timeout_bounds_resolution() { - let started = Instant::now(); - - let result = resolve_within(&mut FakeDht::new(), REALM, PROCEDURE, None, SHORT).await; - - assert!( - matches!(result, Err(ResolveError::ProcedureNotAdvertised)), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - } - - #[tokio::test] - async fn resolve_station_endpoint_timeout_bounds_the_lookup() { - let started = Instant::now(); - - let lookup = lookup_station_endpoint( - &mut FakeDht::new(), - KeyPair::generate().node_id(), - CallDeadline::after(SHORT), - true, - ) - .await; - - assert!( - matches!(lookup.outcome, Err(ResolveError::StationEndpointNotFound)), - "{:?}", - lookup.outcome - ); - assert_returned_within(SHORT_BOUND, started); - } - - #[test] - fn a_candidate_share_splits_what_remains_evenly_with_a_one_second_floor() { - let slack = Duration::from_millis(100); - let about = |share: CallDeadline, expected: Duration| { - let remaining = share.remaining(); - assert!( - remaining <= expected && remaining + slack >= expected, - "expected about {expected:?}, got {remaining:?}" - ); - }; - let roomy = CallDeadline::after(Duration::from_secs(3)); - let tight = CallDeadline::after(Duration::from_millis(300)); - - about(roomy.share_for(2), Duration::from_millis(1500)); - about(roomy.share_for(10), Duration::from_secs(1)); - about(tight.share_for(3), Duration::from_millis(300)); - } - - #[tokio::test] - async fn get_direct_timeout_during_a_transfer_carries_the_last_failure() { - let (a, b) = (Provider::new("a.test"), Provider::new("b.test")); - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.answer( - dht::content_key(mcid), - vec![vec![announcement(&a, mcid), announcement(&b, mcid)]], - ); - let stations = FakeStations::default(); - - let result = fetch_content( - &mut dht.clone(), - mcid, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |host: String, _remaining: Duration| async move { - if host == "a.test" { - return Err("fetched content does not hash to its MCID".to_string()); - } - std::future::pending::<()>().await; - Ok(CONTENT.to_vec()) - }, - Duration::from_secs(1), - ) - .await; - - assert!( - matches!( - &result, - Err(ContentFailure::Timeout(Some(last))) - if matches!(last.as_ref(), ContentFailure::Fetch(e) if e.contains("MCID")) - ), - "{result:?}" - ); - } - - /// The public direct-dial futures must stay `Send`, so a caller can - /// spawn them and the FFI crate, which requires it, keeps building. The - /// check happens at compile time: the closure below is type-checked but - /// never run, so it needs no live session. - #[test] - fn public_direct_dial_futures_are_send() { - fn assert_send(_: &T) {} - let _type_check_only = |session: &Session, id: &KeyPair, mode: StreamMode| { - assert_send(&super::resolve(session, id, REALM, PROCEDURE)); - assert_send(&super::resolve_with_cert_chain( - session, id, REALM, PROCEDURE, b"", ORG, - )); - assert_send(&super::call( - session, - id, - REALM, - PROCEDURE, - Value::Null, - ROOMY, - )); - assert_send(&super::call_with_ucan( - session, - id, - REALM, - PROCEDURE, - Value::Null, - ROOMY, - Vec::new(), - )); - assert_send(&super::call_with_cert_chain( - session, - id, - REALM, - PROCEDURE, - b"", - ORG, - Value::Null, - ROOMY, - )); - assert_send(&super::open_stream_direct( - session, - id, - REALM, - PROCEDURE, - mode, - Value::Null, - 0, - ROOMY, - )); - assert_send(&super::open_stream_direct_with_cert_chain( - session, - id, - REALM, - PROCEDURE, - b"", - ORG, - mode, - Value::Null, - 0, - ROOMY, - )); - assert_send(&super::put_direct(session, id, [0; 32], b"", "name", ROOMY)); - assert_send(&super::get_direct(session, id, [0; 34], ROOMY)); - assert_send(&super::advertise_direct( - session, id, REALM, PROCEDURE, ROOMY, - )); - assert_send(&super::advertise_direct_with_cert_chain( - session, - id, - REALM, - PROCEDURE, - ROOMY, - Vec::new(), - )); - }; - } -} diff --git a/src/frame.rs b/src/frame.rs deleted file mode 100644 index b9f775c..0000000 --- a/src/frame.rs +++ /dev/null @@ -1,2806 +0,0 @@ -//! The macula application-frame envelope: construction, Ed25519 -//! signing/verification, and the length-prefixed wire codec. Ported from -//! `src/peering/macula_frame.erl` (`macula-io/macula`). -//! -//! A wire frame is `<>` where `Cbor` is the -//! deterministic encoding of a single map (see [`crate::cbor`]). Every -//! frame carries a common envelope — `version`, `frame_type`, `frame_id` -//! (UUIDv7), `sent_at_ms`, `capabilities`, plus `realm`/`call_id`/ -//! `source_route` set to `null` unless the specific frame type populates -//! them — and every frame is Ed25519-signed over its own canonical bytes -//! with `signature`/`publisher_sig` stripped first. -//! -//! This module's correctness is checked against a real reference frame: -//! `tests::connect_frame_matches_the_reference_byte_for_byte` builds -//! the exact same CONNECT frame `macula_frame:connect/1` + -//! `macula_frame:sign/2` produced in a live `rebar3 shell` — same -//! identity, same fixed `frame_id`/`sent_at_ms` (injected explicitly, -//! since the reference randomizes both per call and non-determinism -//! would make an exact byte comparison meaningless) — and asserts the -//! encoded bytes, **including the Ed25519 signature itself**, match -//! exactly. That's the strongest test available short of dialing a real -//! station: it proves the canonical-CBOR encoding, the field set, and -//! the signing domain are all bit-for-bit compatible at once. - -use std::time::{SystemTime, UNIX_EPOCH}; - -use crate::cbor::{self, Value}; -use crate::identity::KeyPair; - -/// Domain separator for the per-frame Ed25519 signature (every frame's -/// own `signature` field). Distinct from the SWIM-update and -/// publisher-end-to-end domains documented in -/// `plans/PLAN_WIRE_PROTOCOL.md` §4 — neither of those is implemented -/// here yet. -pub const SIG_DOMAIN: &[u8] = b"macula-v2-frame\0"; - -pub const PROTOCOL_VERSION: i128 = 2; - -/// 16 MiB minus one byte — matches `?MAX_FRAME_BYTES` (`16#FFFFFF`) -/// exactly. -pub const MAX_FRAME_BYTES: usize = 0x00FF_FFFF; - -fn current_millis() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock is after the Unix epoch") - .as_millis() as u64 -} - -fn fresh_frame_id() -> [u8; 16] { - *uuid::Uuid::now_v7().as_bytes() -} - -/// The common envelope every frame carries, matching `base/2`. Field -/// order doesn't matter — canonical CBOR re-sorts by encoded key bytes -/// at encode time regardless (see `crate::cbor`). -fn base( - frame_type: &str, - capabilities: u64, - frame_id: [u8; 16], - sent_at_ms: u64, -) -> Vec<(Value, Value)> { - vec![ - (Value::text("version"), Value::Int(PROTOCOL_VERSION)), - (Value::text("frame_type"), Value::text(frame_type)), - (Value::text("frame_id"), Value::Bytes(frame_id.to_vec())), - (Value::text("sent_at_ms"), Value::Int(sent_at_ms as i128)), - ( - Value::text("capabilities"), - Value::Int(capabilities as i128), - ), - (Value::text("realm"), Value::Null), - (Value::text("call_id"), Value::Null), - (Value::text("source_route"), Value::Null), - ] -} - -fn bytes32_list(items: &[[u8; 32]]) -> Value { - Value::List(items.iter().map(|b| Value::Bytes(b.to_vec())).collect()) -} - -// --------------------------------------------------------------------- -// CONNECT -// --------------------------------------------------------------------- - -/// Fields for a CONNECT frame — see `plans/PLAN_WIRE_PROTOCOL.md` §5. -#[derive(Debug, Clone)] -pub struct ConnectSpec { - pub node_id: [u8; 32], - pub station_id: [u8; 32], - pub realms: Vec<[u8; 32]>, - pub capabilities: u64, - pub puzzle_evidence: [u8; 32], - pub addresses: Vec, - pub site: Option, - pub endorsements: Vec, -} - -impl ConnectSpec { - /// A CONNECT with no realm memberships claimed and no advertised - /// addresses — the shape a dial-out-only leaf client uses (see the - /// spec's §11 discussion of why edge clients never need reachable - /// addresses of their own). - pub fn new(node_id: [u8; 32], puzzle_evidence: [u8; 32]) -> Self { - Self { - node_id, - // `send_connect/2`'s own convention: a plain peer/daemon - // dial sets station_id equal to node_id. - station_id: node_id, - realms: Vec::new(), - capabilities: 0, - puzzle_evidence, - addresses: Vec::new(), - site: None, - endorsements: Vec::new(), - } - } -} - -fn connect_value(spec: &ConnectSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - let mut fields = base("connect", spec.capabilities, frame_id, sent_at_ms); - fields.push((Value::text("node_id"), Value::Bytes(spec.node_id.to_vec()))); - fields.push(( - Value::text("station_id"), - Value::Bytes(spec.station_id.to_vec()), - )); - fields.push((Value::text("realms"), bytes32_list(&spec.realms))); - fields.push(( - Value::text("addresses"), - Value::List(spec.addresses.clone()), - )); - fields.push(( - Value::text("site"), - spec.site.clone().unwrap_or(Value::Null), - )); - fields.push(( - Value::text("puzzle_evidence"), - Value::Bytes(spec.puzzle_evidence.to_vec()), - )); - fields.push(( - Value::text("endorsements"), - Value::List(spec.endorsements.clone()), - )); - Value::Map(fields) -} - -/// Build a CONNECT frame with a fresh `frame_id`/`sent_at_ms`. Unsigned — -/// pass the result to [`sign`] before sending. -pub fn connect(spec: &ConnectSpec) -> Value { - connect_value(spec, fresh_frame_id(), current_millis()) -} - -// --------------------------------------------------------------------- -// GOODBYE -// --------------------------------------------------------------------- - -fn goodbye_value(reason: &str, detail: Option<&str>, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - let mut fields = base("goodbye", 0, frame_id, sent_at_ms); - // `reason` is an Erlang atom() -> text (major 3). `detail` is - // `binary() | undefined` -> a raw byte string (major 2), NOT text — - // caught by the CALL/PUBLISH/etc. differential vectors failing on - // this exact mistake for their own binary()-typed fields (procedure, - // topic). Fixed here too even though no direct GOODBYE vector was - // captured, since it's the identical type. - fields.push((Value::text("reason"), Value::text(reason))); - fields.push(( - Value::text("detail"), - detail - .map(|d| Value::Bytes(d.as_bytes().to_vec())) - .unwrap_or(Value::Null), - )); - Value::Map(fields) -} - -/// Build a GOODBYE frame. `reason` is a short machine-readable code -/// (e.g. `"normal"`); `detail` is an optional human-readable string. -pub fn goodbye(reason: &str, detail: Option<&str>) -> Value { - goodbye_value(reason, detail, fresh_frame_id(), current_millis()) -} - -// --------------------------------------------------------------------- -// CALL / RESULT / ERROR -// -// ⚠ Overriding a base-envelope sentinel field (`realm`, `call_id`, -// `source_route` — all `Null` by default from `base()`) MUST use -// `Value::with_field`, never a raw push onto the field vec. `Value::Map` -// is a plain `Vec<(Value, Value)>`, not a real map — it has none of -// Erlang's automatic key-uniqueness, so appending a second `call_id` -// entry on top of `base()`'s `call_id => Null` would silently produce a -// wire-invalid map with two `call_id` keys instead of overriding it. -// Caught during differential-vector generation against the real -// reference (a hand-built CONNECT test frame subtly differed from -// `macula_frame:call/1`'s own output the same way, before this was -// fixed) — see this crate's own commit history, not hypothetical. -// --------------------------------------------------------------------- - -/// Fields for a CALL frame — see `plans/PLAN_WIRE_PROTOCOL.md` §6.4. -#[derive(Debug, Clone)] -pub struct CallSpec { - pub call_id: [u8; 16], - pub procedure: String, - pub realm: [u8; 32], - pub payload: Value, - pub deadline_ms: i128, - pub caller: [u8; 32], - /// Opaque source-route header bytes (`plans/PLAN_WIRE_PROTOCOL.md` - /// §8) — empty for a direct call to one known station, which is the - /// only shape this crate builds so far. - pub source_route: Vec, - pub retry_budget: u64, - pub ucan_token: Vec, -} - -impl CallSpec { - pub fn new( - call_id: [u8; 16], - procedure: impl Into, - realm: [u8; 32], - payload: Value, - deadline_ms: i128, - caller: [u8; 32], - ) -> Self { - Self { - call_id, - procedure: procedure.into(), - realm, - payload, - deadline_ms, - caller, - source_route: Vec::new(), - retry_budget: 0, - ucan_token: Vec::new(), - } - } -} - -fn call_value(spec: &CallSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - Value::Map(base("call", 0, frame_id, sent_at_ms)) - .with_field("realm", Value::Bytes(spec.realm.to_vec())) - .with_field("call_id", Value::Bytes(spec.call_id.to_vec())) - // `procedure := binary()` in the Erlang spec — a raw byte - // string (major 2), not text (major 3). Confirmed the hard way: - // this was `Value::text(...)` originally and the differential - // vector test caught the resulting signature mismatch. - .with_field( - "procedure", - Value::Bytes(spec.procedure.as_bytes().to_vec()), - ) - .with_field("payload", spec.payload.clone()) - .with_field("deadline_ms", Value::Int(spec.deadline_ms)) - .with_field("caller", Value::Bytes(spec.caller.to_vec())) - .with_field("source_route", Value::Bytes(spec.source_route.clone())) - .with_field("retry_budget", Value::Int(spec.retry_budget as i128)) - .with_field("ucan_token", Value::Bytes(spec.ucan_token.clone())) -} - -/// Build a CALL frame with a fresh `frame_id`/`sent_at_ms`. Unsigned — -/// pass the result to [`sign`] before sending. -pub fn call(spec: &CallSpec) -> Value { - call_value(spec, fresh_frame_id(), current_millis()) -} - -/// Fields for a RESULT frame. -#[derive(Debug, Clone)] -pub struct ResultSpec { - pub call_id: [u8; 16], - pub payload: Value, - pub responded_by: [u8; 32], - pub source_route_reverse: Vec, -} - -impl ResultSpec { - pub fn new(call_id: [u8; 16], payload: Value, responded_by: [u8; 32]) -> Self { - Self { - call_id, - payload, - responded_by, - source_route_reverse: Vec::new(), - } - } -} - -fn result_value(spec: &ResultSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - // NOTE: RESULT does not touch the base envelope's `realm` or - // `source_route` fields at all — they stay `Null`, matching the - // reference exactly (confirmed by inspecting `macula_frame:result/1`'s - // own output directly, not assumed from the CALL pattern above). - // `source_route_reverse` is a distinct field, not a rename. - Value::Map(base("result", 0, frame_id, sent_at_ms)) - .with_field("call_id", Value::Bytes(spec.call_id.to_vec())) - .with_field("payload", spec.payload.clone()) - .with_field("responded_by", Value::Bytes(spec.responded_by.to_vec())) - .with_field( - "source_route_reverse", - Value::Bytes(spec.source_route_reverse.clone()), - ) -} - -/// Build a RESULT frame with a fresh `frame_id`/`sent_at_ms`. -pub fn result(spec: &ResultSpec) -> Value { - result_value(spec, fresh_frame_id(), current_millis()) -} - -/// Fields for an ERROR frame. `name` is derived from `code` automatically -/// (matching `macula_frame:call_error/1`'s own `macula_bolt4:name/1` -/// lookup), not a caller-supplied field. -#[derive(Debug, Clone)] -pub struct CallErrorSpec { - pub call_id: [u8; 16], - pub code: crate::bolt4::Code, - pub reported_by: [u8; 32], - pub detail: Option, - pub offending_hop: Option<[u8; 32]>, - pub source_route_partial: Vec, -} - -impl CallErrorSpec { - pub fn new(call_id: [u8; 16], code: crate::bolt4::Code, reported_by: [u8; 32]) -> Self { - Self { - call_id, - code, - reported_by, - detail: None, - offending_hop: None, - source_route_partial: Vec::new(), - } - } -} - -fn call_error_value(spec: &CallErrorSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - Value::Map(base("error", 0, frame_id, sent_at_ms)) - .with_field("call_id", Value::Bytes(spec.call_id.to_vec())) - .with_field("code", Value::Int(spec.code.as_u8() as i128)) - .with_field("name", Value::text(spec.code.name())) - .with_field("reported_by", Value::Bytes(spec.reported_by.to_vec())) - .with_field( - // `detail => binary() | undefined` — bytes, not text. Same - // fix as CALL's `procedure` and GOODBYE's `detail`. - "detail", - spec.detail - .as_ref() - .map(|d| Value::Bytes(d.as_bytes().to_vec())) - .unwrap_or(Value::Null), - ) - .with_field( - "offending_hop", - spec.offending_hop - .map(|h| Value::Bytes(h.to_vec())) - .unwrap_or(Value::Null), - ) - .with_field( - "source_route_partial", - Value::Bytes(spec.source_route_partial.clone()), - ) -} - -/// Build an ERROR frame with a fresh `frame_id`/`sent_at_ms`. -pub fn call_error(spec: &CallErrorSpec) -> Value { - call_error_value(spec, fresh_frame_id(), current_millis()) -} - -/// The fields a provider needs from an *inbound* CALL — the -/// counterpart to [`CallResponse`] for the receiving side. Still doesn't -/// carry `source_route`/`retry_budget`: nothing in the provider role -/// built so far acts on either. `ucan_token` IS carried — added for -/// [`crate::connection::Session::serve_one_call_gated`]'s policy check, -/// which runs before a handler ever sees the call. -#[derive(Debug, Clone)] -pub struct CallInfo { - pub call_id: [u8; 16], - pub procedure: String, - pub realm: [u8; 32], - pub payload: Value, - pub deadline_ms: i128, - pub caller: [u8; 32], - /// Empty if the caller attached none — matches [`CallSpec::new`]'s - /// own default and `macula_station_link.erl`'s "absent token" case. - pub ucan_token: Vec, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum ParseCallError { - NotACallFrame, - MissingField(&'static str), - WrongFieldType(&'static str), -} - -impl std::fmt::Display for ParseCallError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ParseCallError::NotACallFrame => write!(f, "frame_type is not \"call\""), - ParseCallError::MissingField(name) => write!(f, "missing required field {name:?}"), - ParseCallError::WrongFieldType(name) => write!(f, "field {name:?} has the wrong type"), - } - } -} - -impl std::error::Error for ParseCallError {} - -/// Parse a decoded frame as a CALL — the provider-side counterpart to -/// [`parse_call_response`]. -pub fn parse_call(frame: &Value) -> Result { - match frame.get("frame_type") { - Some(Value::Text(t)) if t == "call" => {} - _ => return Err(ParseCallError::NotACallFrame), - } - let call_id = match frame.get("call_id") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseCallError::WrongFieldType("call_id"))?, - Some(_) => return Err(ParseCallError::WrongFieldType("call_id")), - None => return Err(ParseCallError::MissingField("call_id")), - }; - // `procedure := binary()` on the wire -- bytes, not text. - let procedure = match frame.get("procedure") { - Some(Value::Bytes(b)) => { - String::from_utf8(b.clone()).map_err(|_| ParseCallError::WrongFieldType("procedure"))? - } - Some(_) => return Err(ParseCallError::WrongFieldType("procedure")), - None => return Err(ParseCallError::MissingField("procedure")), - }; - let realm = match frame.get("realm") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseCallError::WrongFieldType("realm"))?, - Some(_) => return Err(ParseCallError::WrongFieldType("realm")), - None => return Err(ParseCallError::MissingField("realm")), - }; - let payload = frame - .get("payload") - .cloned() - .ok_or(ParseCallError::MissingField("payload"))?; - let deadline_ms = match frame.get("deadline_ms") { - Some(Value::Int(n)) => *n, - Some(_) => return Err(ParseCallError::WrongFieldType("deadline_ms")), - None => return Err(ParseCallError::MissingField("deadline_ms")), - }; - let caller = match frame.get("caller") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseCallError::WrongFieldType("caller"))?, - Some(_) => return Err(ParseCallError::WrongFieldType("caller")), - None => return Err(ParseCallError::MissingField("caller")), - }; - let ucan_token = match frame.get("ucan_token") { - Some(Value::Bytes(b)) => b.clone(), - _ => Vec::new(), - }; - Ok(CallInfo { - call_id, - procedure, - realm, - payload, - deadline_ms, - caller, - ucan_token, - }) -} - -/// Parsed fields of a RESULT or ERROR response to a CALL, correlated by -/// `call_id`. Returned by [`crate::connection::Session::call`]. -#[derive(Debug, Clone)] -pub enum CallResponse { - Result { - payload: Value, - responded_by: [u8; 32], - }, - Error { - code: u8, - name: String, - reported_by: [u8; 32], - detail: Option, - }, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum ParseCallResponseError { - NotAResultOrError, - MissingField(&'static str), - WrongFieldType(&'static str), -} - -impl std::fmt::Display for ParseCallResponseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ParseCallResponseError::NotAResultOrError => { - write!(f, "frame_type is neither \"result\" nor \"error\"") - } - ParseCallResponseError::MissingField(name) => { - write!(f, "missing required field {name:?}") - } - ParseCallResponseError::WrongFieldType(name) => { - write!(f, "field {name:?} has the wrong type") - } - } - } -} - -impl std::error::Error for ParseCallResponseError {} - -/// Extract this frame's `call_id`, regardless of frame type — used to -/// correlate a RESULT/ERROR back to the CALL that requested it. 16 -/// bytes, matching `call_id() :: <<_:128>>` — NOT 32; caught only by -/// re-checking against the spec, since the original test for this -/// function made the identical size mistake and so didn't catch it. -pub fn frame_call_id(frame: &Value) -> Option<[u8; 16]> { - match frame.get("call_id") { - Some(Value::Bytes(b)) => b.as_slice().try_into().ok(), - _ => None, - } -} - -/// Parse a decoded frame as a RESULT or ERROR response to a CALL. -pub fn parse_call_response(frame: &Value) -> Result { - match frame.get("frame_type") { - Some(Value::Text(t)) if t == "result" => { - let payload = frame - .get("payload") - .cloned() - .ok_or(ParseCallResponseError::MissingField("payload"))?; - let responded_by = get_bytes32_generic(frame, "responded_by")?; - Ok(CallResponse::Result { - payload, - responded_by, - }) - } - Some(Value::Text(t)) if t == "error" => { - let code = match frame.get("code") { - Some(Value::Int(n)) if (0..=255).contains(n) => *n as u8, - Some(_) => return Err(ParseCallResponseError::WrongFieldType("code")), - None => return Err(ParseCallResponseError::MissingField("code")), - }; - let name = match frame.get("name") { - Some(Value::Text(t)) => t.clone(), - Some(_) => return Err(ParseCallResponseError::WrongFieldType("name")), - None => return Err(ParseCallResponseError::MissingField("name")), - }; - let reported_by = get_bytes32_generic(frame, "reported_by")?; - // `detail` is `binary() | undefined` on the wire (bytes), - // not text -- see call_error_value's own comment. - let detail = match frame.get("detail") { - None | Some(Value::Null) => None, - Some(Value::Bytes(b)) => Some( - String::from_utf8(b.clone()) - .map_err(|_| ParseCallResponseError::WrongFieldType("detail"))?, - ), - Some(_) => return Err(ParseCallResponseError::WrongFieldType("detail")), - }; - Ok(CallResponse::Error { - code, - name, - reported_by, - detail, - }) - } - _ => Err(ParseCallResponseError::NotAResultOrError), - } -} - -fn get_bytes32_generic( - frame: &Value, - field: &'static str, -) -> Result<[u8; 32], ParseCallResponseError> { - match frame.get(field) { - None => Err(ParseCallResponseError::MissingField(field)), - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseCallResponseError::WrongFieldType(field)), - Some(_) => Err(ParseCallResponseError::WrongFieldType(field)), - } -} - -// --------------------------------------------------------------------- -// PUBLISH / SUBSCRIBE / UNSUBSCRIBE / EVENT -// --------------------------------------------------------------------- - -/// Fields for a PUBLISH frame. -#[derive(Debug, Clone)] -pub struct PublishSpec { - pub topic: String, - pub realm: [u8; 32], - pub publisher: [u8; 32], - pub seq: u64, - pub payload: Value, - pub published_at_ms: u64, - pub ttl_ms: Option, -} - -impl PublishSpec { - pub fn new( - topic: impl Into, - realm: [u8; 32], - publisher: [u8; 32], - seq: u64, - payload: Value, - published_at_ms: u64, - ) -> Self { - Self { - topic: topic.into(), - realm, - publisher, - seq, - payload, - published_at_ms, - ttl_ms: None, - } - } -} - -fn publish_value(spec: &PublishSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - Value::Map(base("publish", 0, frame_id, sent_at_ms)) - .with_field("realm", Value::Bytes(spec.realm.to_vec())) - // `topic := binary()` -- bytes, not text. Same fix as CALL's - // `procedure`. - .with_field("topic", Value::Bytes(spec.topic.as_bytes().to_vec())) - .with_field("publisher", Value::Bytes(spec.publisher.to_vec())) - .with_field("seq", Value::Int(spec.seq as i128)) - .with_field("payload", spec.payload.clone()) - .with_field("published_at_ms", Value::Int(spec.published_at_ms as i128)) - .with_field( - "ttl_ms", - spec.ttl_ms - .map(|t| Value::Int(t as i128)) - .unwrap_or(Value::Null), - ) -} - -/// Build a PUBLISH frame with a fresh `frame_id`/`sent_at_ms`. Does not -/// set `publisher_sig` (the separate end-to-end publisher signature, -/// §4/§6.8 of the spec) — not implemented by this crate yet. -pub fn publish(spec: &PublishSpec) -> Value { - publish_value(spec, fresh_frame_id(), current_millis()) -} - -/// Fields for a SUBSCRIBE frame. -#[derive(Debug, Clone)] -pub struct SubscribeSpec { - pub topic: String, - pub realm: [u8; 32], - pub subscriber: [u8; 32], -} - -impl SubscribeSpec { - pub fn new(topic: impl Into, realm: [u8; 32], subscriber: [u8; 32]) -> Self { - Self { - topic: topic.into(), - realm, - subscriber, - } - } -} - -fn subscribe_value(spec: &SubscribeSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - Value::Map(base("subscribe", 0, frame_id, sent_at_ms)) - .with_field("realm", Value::Bytes(spec.realm.to_vec())) - // `topic := binary()` -- bytes, not text. Same fix as CALL's - // `procedure`. - .with_field("topic", Value::Bytes(spec.topic.as_bytes().to_vec())) - .with_field("subscriber", Value::Bytes(spec.subscriber.to_vec())) - .with_field("filter", Value::Null) - .with_field("options", Value::Map(vec![])) -} - -/// Build a SUBSCRIBE frame with a fresh `frame_id`/`sent_at_ms`. No -/// filter, no options — the plainest possible subscription. -pub fn subscribe(spec: &SubscribeSpec) -> Value { - subscribe_value(spec, fresh_frame_id(), current_millis()) -} - -/// Fields for an UNSUBSCRIBE frame. -#[derive(Debug, Clone)] -pub struct UnsubscribeSpec { - pub topic: String, - pub realm: [u8; 32], - pub subscriber: [u8; 32], -} - -impl UnsubscribeSpec { - pub fn new(topic: impl Into, realm: [u8; 32], subscriber: [u8; 32]) -> Self { - Self { - topic: topic.into(), - realm, - subscriber, - } - } -} - -fn unsubscribe_value(spec: &UnsubscribeSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - Value::Map(base("unsubscribe", 0, frame_id, sent_at_ms)) - .with_field("realm", Value::Bytes(spec.realm.to_vec())) - // `topic := binary()` -- bytes, not text. Same fix as CALL's - // `procedure`. - .with_field("topic", Value::Bytes(spec.topic.as_bytes().to_vec())) - .with_field("subscriber", Value::Bytes(spec.subscriber.to_vec())) -} - -/// Build an UNSUBSCRIBE frame with a fresh `frame_id`/`sent_at_ms`. -pub fn unsubscribe(spec: &UnsubscribeSpec) -> Value { - unsubscribe_value(spec, fresh_frame_id(), current_millis()) -} - -/// What a subscriber actually receives — parsed fields of an EVENT frame. -#[derive(Debug, Clone)] -pub struct EventInfo { - pub topic: String, - pub realm: [u8; 32], - pub publisher: [u8; 32], - pub seq: u64, - pub payload: Value, - pub delivered_via: String, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum ParseEventError { - NotAnEventFrame, - MissingField(&'static str), - WrongFieldType(&'static str), -} - -impl std::fmt::Display for ParseEventError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ParseEventError::NotAnEventFrame => write!(f, "frame_type is not \"event\""), - ParseEventError::MissingField(name) => write!(f, "missing required field {name:?}"), - ParseEventError::WrongFieldType(name) => write!(f, "field {name:?} has the wrong type"), - } - } -} - -impl std::error::Error for ParseEventError {} - -/// Parse a decoded frame as an EVENT. -pub fn parse_event(frame: &Value) -> Result { - match frame.get("frame_type") { - Some(Value::Text(t)) if t == "event" => {} - _ => return Err(ParseEventError::NotAnEventFrame), - } - // `topic := binary()` on the wire -- bytes, not text. - let topic = match frame.get("topic") { - Some(Value::Bytes(b)) => { - String::from_utf8(b.clone()).map_err(|_| ParseEventError::WrongFieldType("topic"))? - } - Some(_) => return Err(ParseEventError::WrongFieldType("topic")), - None => return Err(ParseEventError::MissingField("topic")), - }; - let realm = match frame.get("realm") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseEventError::WrongFieldType("realm"))?, - Some(_) => return Err(ParseEventError::WrongFieldType("realm")), - None => return Err(ParseEventError::MissingField("realm")), - }; - let publisher = match frame.get("publisher") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseEventError::WrongFieldType("publisher"))?, - Some(_) => return Err(ParseEventError::WrongFieldType("publisher")), - None => return Err(ParseEventError::MissingField("publisher")), - }; - let seq = match frame.get("seq") { - Some(Value::Int(n)) if *n >= 0 => *n as u64, - Some(_) => return Err(ParseEventError::WrongFieldType("seq")), - None => return Err(ParseEventError::MissingField("seq")), - }; - let payload = frame - .get("payload") - .cloned() - .ok_or(ParseEventError::MissingField("payload"))?; - let delivered_via = match frame.get("delivered_via") { - Some(Value::Text(t)) => t.clone(), - Some(_) => return Err(ParseEventError::WrongFieldType("delivered_via")), - None => return Err(ParseEventError::MissingField("delivered_via")), - }; - Ok(EventInfo { - topic, - realm, - publisher, - seq, - payload, - delivered_via, - }) -} - -// --------------------------------------------------------------------- -// HELLO (parse only — a client receives these, it doesn't construct them) -// --------------------------------------------------------------------- - -/// The fields of a HELLO frame actually needed to drive the handshake -/// state machine (`plans/PLAN_WIRE_PROTOCOL.md` §3). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HelloInfo { - pub node_id: [u8; 32], - pub station_id: [u8; 32], - pub realms: Vec<[u8; 32]>, - pub capabilities: u64, - pub accepted: bool, - pub negotiated_capabilities: u64, - pub refusal_code: Option, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum ParseHelloError { - NotAHelloFrame, - MissingField(&'static str), - WrongFieldType(&'static str), -} - -impl std::fmt::Display for ParseHelloError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ParseHelloError::NotAHelloFrame => write!(f, "frame_type is not \"hello\""), - ParseHelloError::MissingField(name) => write!(f, "missing required field {name:?}"), - ParseHelloError::WrongFieldType(name) => write!(f, "field {name:?} has the wrong type"), - } - } -} - -impl std::error::Error for ParseHelloError {} - -fn get_bytes32(frame: &Value, field: &'static str) -> Result<[u8; 32], ParseHelloError> { - match frame.get(field) { - None => Err(ParseHelloError::MissingField(field)), - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseHelloError::WrongFieldType(field)), - Some(_) => Err(ParseHelloError::WrongFieldType(field)), - } -} - -fn get_bytes32_list(frame: &Value, field: &'static str) -> Result, ParseHelloError> { - match frame.get(field) { - None => Err(ParseHelloError::MissingField(field)), - Some(Value::List(items)) => items - .iter() - .map(|v| match v { - Value::Bytes(b) => b - .as_slice() - .try_into() - .map_err(|_| ParseHelloError::WrongFieldType(field)), - _ => Err(ParseHelloError::WrongFieldType(field)), - }) - .collect(), - Some(_) => Err(ParseHelloError::WrongFieldType(field)), - } -} - -fn get_uint(frame: &Value, field: &'static str) -> Result { - match frame.get(field) { - None => Err(ParseHelloError::MissingField(field)), - Some(Value::Int(n)) if *n >= 0 => Ok(*n as u64), - Some(_) => Err(ParseHelloError::WrongFieldType(field)), - } -} - -fn get_bool(frame: &Value, field: &'static str) -> Result { - match frame.get(field) { - None => Err(ParseHelloError::MissingField(field)), - Some(Value::Text(t)) if t == "true" => Ok(true), - Some(Value::Text(t)) if t == "false" => Ok(false), - Some(_) => Err(ParseHelloError::WrongFieldType(field)), - } -} - -/// Parse a decoded frame as a HELLO, checking `frame_type` first. -pub fn parse_hello(frame: &Value) -> Result { - match frame.get("frame_type") { - Some(Value::Text(t)) if t == "hello" => {} - _ => return Err(ParseHelloError::NotAHelloFrame), - } - let refusal_code = match frame.get("refusal_code") { - None | Some(Value::Null) => None, - Some(Value::Int(n)) => Some(*n), - Some(_) => return Err(ParseHelloError::WrongFieldType("refusal_code")), - }; - Ok(HelloInfo { - node_id: get_bytes32(frame, "node_id")?, - station_id: get_bytes32(frame, "station_id")?, - realms: get_bytes32_list(frame, "realms")?, - capabilities: get_uint(frame, "capabilities")?, - accepted: get_bool(frame, "accepted")?, - negotiated_capabilities: get_uint(frame, "negotiated_capabilities")?, - refusal_code, - }) -} - -// --------------------------------------------------------------------- -// Sign / verify -// --------------------------------------------------------------------- - -/// Sign `frame` with `identity`, over `SIG_DOMAIN || canonical_cbor(frame -/// minus signature/publisher_sig)`, and return the frame with its -/// `signature` field set (64 bytes). -pub fn sign(frame: Value, identity: &KeyPair) -> Value { - let signable = signable_bytes(&frame); - let sig = identity.sign(&signable); - frame.with_field("signature", Value::Bytes(sig.to_vec())) -} - -fn signable_bytes(frame: &Value) -> Vec { - let unsigned = frame.without(&["signature", "publisher_sig"]); - let canonical = - cbor::encode(&unsigned).expect("a frame built by this module is always encodable"); - let mut out = Vec::with_capacity(SIG_DOMAIN.len() + canonical.len()); - out.extend_from_slice(SIG_DOMAIN); - out.extend_from_slice(&canonical); - out -} - -#[derive(Debug, PartialEq, Eq)] -pub enum VerifyError { - MissingSignature, - BadSignature, - SignatureInvalid, -} - -impl std::fmt::Display for VerifyError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - VerifyError::MissingSignature => write!(f, "frame has no signature field"), - VerifyError::BadSignature => write!(f, "signature field is not 64 bytes"), - VerifyError::SignatureInvalid => write!(f, "signature does not verify against pubkey"), - } - } -} - -impl std::error::Error for VerifyError {} - -/// Verify `frame`'s `signature` field against `pubkey`, over the same -/// domain-separated bytes [`sign`] produces. -pub fn verify(frame: &Value, pubkey: &[u8; 32]) -> Result<(), VerifyError> { - let sig: [u8; 64] = match frame.get("signature") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| VerifyError::BadSignature)?, - _ => return Err(VerifyError::MissingSignature), - }; - let signable = signable_bytes(frame); - if crate::identity::verify(&signable, &sig, pubkey) { - Ok(()) - } else { - Err(VerifyError::SignatureInvalid) - } -} - -// --------------------------------------------------------------------- -// publisher_sig: the separate end-to-end signature on PUBLISH/EVENT -// frames (§4/§6.6, §6.8). `sign`/`verify` above cover a frame's own -// per-hop `signature`, which is checked against whichever connection -// the frame arrived on -- correct for the frame's origin (hop 1), but -// wrong for any further relay hop, since a relayed frame's signature -// still belongs to the ORIGINAL sender, not whichever station forwarded -// it. `publisher_sig` covers just (topic, realm, publisher, seq, -// payload), independent of frame type, so it survives PUBLISH -> EVENT -// conversion and every relay hop -- a receiving station or client can -// verify authenticity against the ORIGINAL publisher no matter how many -// stations forwarded it. Ported from the Erlang reference -// (macula_frame.erl:sign_publisher/2, ?EVENT_PUBLISHER_DOMAIN) and -// checked byte-for-byte against a signature generated live from that -// same code (frame::tests::publisher_sig_matches_the_erlang_reference). -// --------------------------------------------------------------------- - -pub const EVENT_PUBLISHER_DOMAIN: &[u8] = b"macula-v2-event-pub\0"; - -/// Add `publisher_sig` to a PUBLISH or EVENT frame: `identity`'s Ed25519 -/// signature over `(topic, realm, publisher, seq, payload)`. `identity` -/// must be the key pair for the pubkey already in the frame's -/// `publisher` field -- this is not checked here (callers build frames -/// with their own identity's pubkey as `publisher` by construction). -pub fn sign_publisher(frame: Value, identity: &KeyPair) -> Value { - let signable = publisher_signing_bytes(&frame); - let sig = identity.sign(&signable); - frame.with_field("publisher_sig", Value::Bytes(sig.to_vec())) -} - -#[derive(Debug, PartialEq, Eq)] -pub enum VerifyPublisherError { - MissingPublisherSig, - BadPublisherSig, - PublisherSigInvalid, -} - -impl std::fmt::Display for VerifyPublisherError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - VerifyPublisherError::MissingPublisherSig => { - write!(f, "frame has no publisher_sig field") - } - VerifyPublisherError::BadPublisherSig => { - write!(f, "publisher_sig field is not 64 bytes") - } - VerifyPublisherError::PublisherSigInvalid => write!( - f, - "publisher_sig does not verify against the frame's publisher field" - ), - } - } -} - -impl std::error::Error for VerifyPublisherError {} - -/// Verify `frame`'s `publisher_sig` against its OWN `publisher` field -- -/// unlike [`verify`] (the per-hop signature), there is no separate -/// pubkey parameter: `publisher_sig`'s whole point is proving "the -/// pubkey named in this frame produced it", independent of which -/// connection it arrived on. -pub fn verify_publisher(frame: &Value) -> Result<(), VerifyPublisherError> { - let sig: [u8; 64] = match frame.get("publisher_sig") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| VerifyPublisherError::BadPublisherSig)?, - _ => return Err(VerifyPublisherError::MissingPublisherSig), - }; - let pubkey: [u8; 32] = match frame.get("publisher") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| VerifyPublisherError::BadPublisherSig)?, - _ => return Err(VerifyPublisherError::BadPublisherSig), - }; - let signable = publisher_signing_bytes(frame); - if crate::identity::verify(&signable, &sig, &pubkey) { - Ok(()) - } else { - Err(VerifyPublisherError::PublisherSigInvalid) - } -} - -/// The canonical bytes a publisher signs: a fixed 5-field tuple, -/// independent of frame type, header fields, `delivered_via`, or -/// `ttl_ms`, so the same signature is valid on the PUBLISH the -/// publisher sent and on every EVENT a relay derives from it. -fn publisher_signing_bytes(frame: &Value) -> Vec { - let fields = ["topic", "realm", "publisher", "seq", "payload"]; - let pairs: Vec<(Value, Value)> = fields - .iter() - .map(|f| { - let v = frame.get(f).cloned().unwrap_or(Value::Null); - (Value::text(*f), v) - }) - .collect(); - let canonical = - cbor::encode(&Value::Map(pairs)).expect("a frame built by this module is always encodable"); - let mut out = Vec::with_capacity(EVENT_PUBLISHER_DOMAIN.len() + canonical.len()); - out.extend_from_slice(EVENT_PUBLISHER_DOMAIN); - out.extend_from_slice(&canonical); - out -} - -// --------------------------------------------------------------------- -// Wire codec: length-prefixed CBOR -// --------------------------------------------------------------------- - -#[derive(Debug, PartialEq, Eq)] -pub enum EncodeFrameError { - TooLarge(usize), - Cbor(cbor::IntOutOfRange), -} - -impl std::fmt::Display for EncodeFrameError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - EncodeFrameError::TooLarge(n) => { - write!( - f, - "frame is {n} bytes, exceeding the {MAX_FRAME_BYTES}-byte cap" - ) - } - EncodeFrameError::Cbor(e) => write!(f, "{e}"), - } - } -} - -impl std::error::Error for EncodeFrameError {} - -/// Encode `frame` as `<>`. -pub fn encode(frame: &Value) -> Result, EncodeFrameError> { - let payload = cbor::encode(frame).map_err(EncodeFrameError::Cbor)?; - if payload.len() > MAX_FRAME_BYTES { - return Err(EncodeFrameError::TooLarge(payload.len())); - } - let mut out = Vec::with_capacity(4 + payload.len()); - out.extend_from_slice(&(payload.len() as u32).to_be_bytes()); - out.extend_from_slice(&payload); - Ok(out) -} - -/// Result of attempting to decode one frame from the head of a buffer — -/// mirrors the reference decoder's three-way `{ok,_,_}` / `{more,_}` / -/// `{error,_}` contract, adapted to return a consumed-byte count instead -/// of a remainder slice (equally usable, more idiomatic here). -#[derive(Debug)] -pub enum Decoded { - /// A complete frame was decoded, consuming this many bytes from the - /// front of the buffer. - Frame(Value, usize), - /// The buffer doesn't yet hold a complete frame; at least this many - /// more bytes are needed before trying again. - More(usize), -} - -#[derive(Debug)] -pub enum DecodeFrameError { - TooLarge(usize), - Cbor(cbor::DecodeError), -} - -impl std::fmt::Display for DecodeFrameError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - DecodeFrameError::TooLarge(n) => { - write!( - f, - "claimed frame length {n} exceeds the {MAX_FRAME_BYTES}-byte cap" - ) - } - DecodeFrameError::Cbor(e) => write!(f, "{e}"), - } - } -} - -impl std::error::Error for DecodeFrameError {} - -/// Decode one length-prefixed frame from the head of `buf`. -pub fn decode(buf: &[u8]) -> Result { - if buf.len() < 4 { - return Ok(Decoded::More(4 - buf.len())); - } - let len = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize; - if len > MAX_FRAME_BYTES { - return Err(DecodeFrameError::TooLarge(len)); - } - if buf.len() < 4 + len { - return Ok(Decoded::More(4 + len - buf.len())); - } - let value = cbor::decode(&buf[4..4 + len]).map_err(DecodeFrameError::Cbor)?; - Ok(Decoded::Frame(value, 4 + len)) -} - -// --------------------------------------------------------------------- -// RPC advertise (§6.9 of `plans/PLAN_WIRE_PROTOCOL.md`): ADVERTISE, -// UNADVERTISE. The provider-role building block — registers this -// connection as the handler for `procedure` under `realm`; the station -// then routes inbound CALLs (control stream) and STREAM_OPENs (a fresh -// dedicated stream it opens toward us) for that procedure back to us. -// See `src/provider.rs` for the dispatch side of that, once built. -// --------------------------------------------------------------------- - -/// Fields for an ADVERTISE frame. -#[derive(Debug, Clone)] -pub struct AdvertiseSpec { - pub realm: [u8; 32], - pub procedure: String, - pub advertiser: [u8; 32], -} - -impl AdvertiseSpec { - pub fn new(realm: [u8; 32], procedure: impl Into, advertiser: [u8; 32]) -> Self { - Self { - realm, - procedure: procedure.into(), - advertiser, - } - } -} - -fn advertise_value(spec: &AdvertiseSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - // NOTE: `source_route` stays untouched (`Null`) — confirmed directly - // against the reference, not assumed from CALL/STREAM_OPEN's pattern - // (which DO override it). `realm` IS overridden here, unlike RESULT/ - // STREAM_DATA/etc. - Value::Map(base("advertise", 0, frame_id, sent_at_ms)) - .with_field("realm", Value::Bytes(spec.realm.to_vec())) - // `procedure := binary()` -- bytes, not text. Same fix as CALL's - // `procedure`. - .with_field( - "procedure", - Value::Bytes(spec.procedure.as_bytes().to_vec()), - ) - .with_field("advertiser", Value::Bytes(spec.advertiser.to_vec())) - // `options` has no known use case yet -- always the reference's - // own default, an empty map. - .with_field("options", Value::Map(vec![])) -} - -/// Build an ADVERTISE frame with a fresh `frame_id`/`sent_at_ms`. -pub fn advertise(spec: &AdvertiseSpec) -> Value { - advertise_value(spec, fresh_frame_id(), current_millis()) -} - -/// Fields for an UNADVERTISE frame. -#[derive(Debug, Clone)] -pub struct UnadvertiseSpec { - pub realm: [u8; 32], - pub procedure: String, - pub advertiser: [u8; 32], -} - -impl UnadvertiseSpec { - pub fn new(realm: [u8; 32], procedure: impl Into, advertiser: [u8; 32]) -> Self { - Self { - realm, - procedure: procedure.into(), - advertiser, - } - } -} - -fn unadvertise_value(spec: &UnadvertiseSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - Value::Map(base("unadvertise", 0, frame_id, sent_at_ms)) - .with_field("realm", Value::Bytes(spec.realm.to_vec())) - .with_field( - "procedure", - Value::Bytes(spec.procedure.as_bytes().to_vec()), - ) - .with_field("advertiser", Value::Bytes(spec.advertiser.to_vec())) -} - -/// Build an UNADVERTISE frame with a fresh `frame_id`/`sent_at_ms`. -pub fn unadvertise(spec: &UnadvertiseSpec) -> Value { - unadvertise_value(spec, fresh_frame_id(), current_millis()) -} - -// --------------------------------------------------------------------- -// Streaming RPC (§13 of `plans/PLAN_WIRE_PROTOCOL.md`): STREAM_OPEN, -// STREAM_DATA, STREAM_END, STREAM_ERROR, STREAM_REPLY. Ported from -// `macula_frame.erl`'s streaming constructors, verified against real -// `rebar3` output the same way as every other frame type. -// -// **Real finding, empirically verified (2026-08-28), correcting an -// assumption in an earlier draft of the wire-protocol spec:** despite -// `encoding`'s `msgpack` value name, there is no second wire codec. -// `msgpack` was removed from macula's own dependencies in v3.0.0 -// (`rebar.config`'s own comment: "wire protocol switched to CBOR"); the -// one remaining `msgpack:pack` call in the whole macula repo is in an -// unrelated legacy DHT test, never on the `stream_data` path. Confirmed -// directly: building a `stream_data` frame with `encoding = msgpack` and -// an arbitrary Erlang map as `body`, then round-tripping it through -// `macula_frame:encode/1` + `decode/1`, hands the map straight back — -// `body` is embedded as an ordinary nested value in the frame's own -// canonical-CBOR envelope, exactly like CALL's `payload` or -// `stream_reply`'s `payload`. So here, `encoding` is purely a semantic -// hint for the receiver ("treat `body` as raw bytes" vs "treat it as a -// structured value") — `StreamDataSpec::body` is just a [`Value`] either -// way, and no `rmp-serde`/msgpack dependency is needed in this crate. -// -// **v1 scope, matching this crate's existing priority (also documented -// in the plan): the caller/consumer role (§13.1) only.** These -// constructors are enough to open a stream, send/receive chunks, close -// or abort — the shape a mobile client actually needs. The provider -// role (§13.2, exposing a streaming procedure *to* the mesh) isn't -// built — nothing in this crate needs to *serve* RPCs yet. -// -// **Correction, 2026-08-29 — the assumption below was wrong, found live -// against the real fleet.** `signer` (an optional field on -// STREAM_DATA/STREAM_END/STREAM_ERROR, mirrors the reference's -// `maybe_add_signer/2`) IS now stamped by every real call site in this -// crate (`stream::StreamHandle::send_data`/`close_send`/`abort`, which -// always pass `Some(identity.public_bytes())`). The original reasoning — -// "a direct-dial client talking to one station has no relay hop to -// authenticate across" — assumed the client's own single hop is the -// only hop that matters. It isn't: the STATION side can still relay the -// stream on to a SECOND station if the advertised provider lives -// elsewhere (`macula_station_peer_observer.erl`'s dedicated-stream -// dispatch is built to do exactly this). Without `signer`, the second -// hop's verify falls back to the inbound connection's NodeId — which at -// that hop is the relaying station's own identity, not the original -// caller's — and the reference's own comment on `maybe_add_signer/2` -// says as much: "fine for the direct edge... fails on every subsequent -// station-to-station hop". Confirmed live: `tests/live_station.rs`'s -// `cross_station_streaming_round_trip_frankfurt_provider_milan_caller` -// found exactly this failure mode (STREAM_OPEN routes cross-station, -// STREAM_DATA silently never arrives) before this field was wired up. -// CALL/PUBLISH don't need this because they're signed end-to-end by a -// REQUIRED field (`caller`/`responded_by`) present on every frame -// regardless of hop count — `signer` gives STREAM_DATA/END/ERROR the -// same property, just as an optional field instead of a required one, -// matching the reference's own design exactly. - -/// `mode` on a STREAM_OPEN — who's expected to push data. Matches -/// `macula_stream:mode()`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StreamMode { - /// The provider pushes chunks at the caller. - ServerStream, - /// The caller pushes chunks at the provider (§12.3's push-upload - /// path is exactly this mode). - ClientStream, - /// Both directions. - Bidi, -} - -impl StreamMode { - pub fn name(self) -> &'static str { - match self { - StreamMode::ServerStream => "server_stream", - StreamMode::ClientStream => "client_stream", - StreamMode::Bidi => "bidi", - } - } - - fn from_name(name: &str) -> Option { - match name { - "server_stream" => Some(StreamMode::ServerStream), - "client_stream" => Some(StreamMode::ClientStream), - "bidi" => Some(StreamMode::Bidi), - _ => None, - } - } -} - -/// `encoding` on a STREAM_DATA — a hint for how to interpret `body`, not -/// a second wire codec. See this section's module-level note. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StreamEncoding { - /// `body` is opaque bytes. - Raw, - /// `body` is a structured [`Value`] (despite the name — no msgpack - /// byte-level encoding actually happens; see the note above). - Msgpack, -} - -impl StreamEncoding { - pub fn name(self) -> &'static str { - match self { - StreamEncoding::Raw => "raw", - StreamEncoding::Msgpack => "msgpack", - } - } - - fn from_name(name: &str) -> Option { - match name { - "raw" => Some(StreamEncoding::Raw), - "msgpack" => Some(StreamEncoding::Msgpack), - _ => None, - } - } -} - -/// `role` on a STREAM_END — which direction(s) are closing. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StreamRole { - /// Half-close: this side is done sending, still willing to receive. - Send, - /// Full close: this side is done in both directions. - Both, -} - -impl StreamRole { - pub fn name(self) -> &'static str { - match self { - StreamRole::Send => "send", - StreamRole::Both => "both", - } - } - - fn from_name(name: &str) -> Option { - match name { - "send" => Some(StreamRole::Send), - "both" => Some(StreamRole::Both), - _ => None, - } - } -} - -/// Fields for a STREAM_OPEN frame. Mirrors CALL's auth/routing shape — -/// `deadline_ms`/`caller`/`source_route`/`retry_budget` — plus the -/// stream-specific `stream_id`/`mode`/`args`. -#[derive(Debug, Clone)] -pub struct StreamOpenSpec { - pub stream_id: [u8; 16], - pub procedure: String, - pub realm: [u8; 32], - pub mode: StreamMode, - pub args: Value, - pub deadline_ms: i128, - pub caller: [u8; 32], - pub source_route: Vec, - pub retry_budget: u64, -} - -impl StreamOpenSpec { - pub fn new( - stream_id: [u8; 16], - procedure: impl Into, - realm: [u8; 32], - mode: StreamMode, - args: Value, - deadline_ms: i128, - caller: [u8; 32], - ) -> Self { - Self { - stream_id, - procedure: procedure.into(), - realm, - mode, - args, - deadline_ms, - caller, - source_route: Vec::new(), - retry_budget: 0, - } - } -} - -fn stream_open_value(spec: &StreamOpenSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - Value::Map(base("stream_open", 0, frame_id, sent_at_ms)) - .with_field("stream_id", Value::Bytes(spec.stream_id.to_vec())) - // `procedure := binary()` -- bytes, not text. Same fix as CALL's - // `procedure`. - .with_field( - "procedure", - Value::Bytes(spec.procedure.as_bytes().to_vec()), - ) - .with_field("realm", Value::Bytes(spec.realm.to_vec())) - .with_field("mode", Value::text(spec.mode.name())) - .with_field("args", spec.args.clone()) - .with_field("deadline_ms", Value::Int(spec.deadline_ms)) - .with_field("caller", Value::Bytes(spec.caller.to_vec())) - .with_field("source_route", Value::Bytes(spec.source_route.clone())) - .with_field("retry_budget", Value::Int(spec.retry_budget as i128)) -} - -/// Build a STREAM_OPEN frame with a fresh `frame_id`/`sent_at_ms`. -/// Unsigned — pass the result to [`sign`] before sending. -pub fn stream_open(spec: &StreamOpenSpec) -> Value { - stream_open_value(spec, fresh_frame_id(), current_millis()) -} - -/// The fields a provider needs from an *inbound* STREAM_OPEN — the -/// first frame on a freshly-accepted dedicated stream (§13.2). Doesn't -/// carry `source_route`/`retry_budget`: nothing in the provider role -/// built so far acts on either. -#[derive(Debug, Clone)] -pub struct StreamOpenInfo { - pub stream_id: [u8; 16], - pub procedure: String, - pub realm: [u8; 32], - pub mode: StreamMode, - pub args: Value, - pub deadline_ms: i128, - pub caller: [u8; 32], -} - -#[derive(Debug, PartialEq, Eq)] -pub enum ParseStreamOpenError { - NotAStreamOpenFrame, - MissingField(&'static str), - WrongFieldType(&'static str), -} - -impl std::fmt::Display for ParseStreamOpenError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ParseStreamOpenError::NotAStreamOpenFrame => { - write!(f, "frame_type is not \"stream_open\"") - } - ParseStreamOpenError::MissingField(name) => { - write!(f, "missing required field {name:?}") - } - ParseStreamOpenError::WrongFieldType(name) => { - write!(f, "field {name:?} has the wrong type") - } - } - } -} - -impl std::error::Error for ParseStreamOpenError {} - -/// Parse a decoded frame as a STREAM_OPEN. -pub fn parse_stream_open(frame: &Value) -> Result { - match frame.get("frame_type") { - Some(Value::Text(t)) if t == "stream_open" => {} - _ => return Err(ParseStreamOpenError::NotAStreamOpenFrame), - } - let stream_id = match frame.get("stream_id") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseStreamOpenError::WrongFieldType("stream_id"))?, - Some(_) => return Err(ParseStreamOpenError::WrongFieldType("stream_id")), - None => return Err(ParseStreamOpenError::MissingField("stream_id")), - }; - // `procedure := binary()` on the wire -- bytes, not text. - let procedure = match frame.get("procedure") { - Some(Value::Bytes(b)) => String::from_utf8(b.clone()) - .map_err(|_| ParseStreamOpenError::WrongFieldType("procedure"))?, - Some(_) => return Err(ParseStreamOpenError::WrongFieldType("procedure")), - None => return Err(ParseStreamOpenError::MissingField("procedure")), - }; - let realm = match frame.get("realm") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseStreamOpenError::WrongFieldType("realm"))?, - Some(_) => return Err(ParseStreamOpenError::WrongFieldType("realm")), - None => return Err(ParseStreamOpenError::MissingField("realm")), - }; - let mode = match frame.get("mode") { - Some(Value::Text(t)) => { - StreamMode::from_name(t).ok_or(ParseStreamOpenError::WrongFieldType("mode"))? - } - Some(_) => return Err(ParseStreamOpenError::WrongFieldType("mode")), - None => return Err(ParseStreamOpenError::MissingField("mode")), - }; - let args = frame - .get("args") - .cloned() - .ok_or(ParseStreamOpenError::MissingField("args"))?; - let deadline_ms = match frame.get("deadline_ms") { - Some(Value::Int(n)) => *n, - Some(_) => return Err(ParseStreamOpenError::WrongFieldType("deadline_ms")), - None => return Err(ParseStreamOpenError::MissingField("deadline_ms")), - }; - let caller = match frame.get("caller") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseStreamOpenError::WrongFieldType("caller"))?, - Some(_) => return Err(ParseStreamOpenError::WrongFieldType("caller")), - None => return Err(ParseStreamOpenError::MissingField("caller")), - }; - Ok(StreamOpenInfo { - stream_id, - procedure, - realm, - mode, - args, - deadline_ms, - caller, - }) -} - -/// Fields for a STREAM_DATA frame — one chunk. `body`'s shape follows -/// `encoding`: [`Value::Bytes`] for [`StreamEncoding::Raw`], any -/// structured [`Value`] for [`StreamEncoding::Msgpack`] (see this -/// section's module-level note on why that's still a plain CBOR value, -/// not a second codec). -/// -/// `signer`: see this section's module doc for why every real call site -/// in this crate ([`crate::stream::StreamHandle`]) always supplies -/// `Some(identity.public_bytes())` — `None` exists only because the -/// reference's own `maybe_add_signer/2` treats it as optional, and two -/// of this module's own differential vectors deliberately exercise that -/// branch (signature bytes captured before this crate carried `signer` -/// at all, still valid against the reference today). -#[derive(Debug, Clone)] -pub struct StreamDataSpec { - pub stream_id: [u8; 16], - pub seq: u64, - pub encoding: StreamEncoding, - pub body: Value, - pub signer: Option<[u8; 32]>, -} - -impl StreamDataSpec { - pub fn new( - stream_id: [u8; 16], - seq: u64, - encoding: StreamEncoding, - body: Value, - signer: Option<[u8; 32]>, - ) -> Self { - Self { - stream_id, - seq, - encoding, - body, - signer, - } - } -} - -fn stream_data_value(spec: &StreamDataSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - // NOTE: like RESULT, STREAM_DATA does not touch the base envelope's - // `realm`/`call_id`/`source_route` — they stay `Null`, confirmed - // directly against the reference's own output, not assumed from - // STREAM_OPEN's pattern. - let value = Value::Map(base("stream_data", 0, frame_id, sent_at_ms)) - .with_field("stream_id", Value::Bytes(spec.stream_id.to_vec())) - .with_field("seq", Value::Int(spec.seq as i128)) - .with_field("encoding", Value::text(spec.encoding.name())) - .with_field("body", spec.body.clone()); - with_optional_signer(value, spec.signer) -} - -/// Build a STREAM_DATA frame with a fresh `frame_id`/`sent_at_ms`. -pub fn stream_data(spec: &StreamDataSpec) -> Value { - stream_data_value(spec, fresh_frame_id(), current_millis()) -} - -/// Mirrors the reference's `maybe_add_signer/2` exactly: stamp `signer` -/// onto the frame when present, leave the frame untouched otherwise — -/// see [`StreamDataSpec::signer`]'s doc for why this exists at all. -fn with_optional_signer(value: Value, signer: Option<[u8; 32]>) -> Value { - match signer { - Some(pub_key) => value.with_field("signer", Value::Bytes(pub_key.to_vec())), - None => value, - } -} - -/// Fields for a STREAM_END frame — a half-close (`role: Send`) or full -/// close (`role: Both`) of one direction. See [`StreamDataSpec::signer`]'s -/// doc — same field, same reasoning. -#[derive(Debug, Clone)] -pub struct StreamEndSpec { - pub stream_id: [u8; 16], - pub role: StreamRole, - pub signer: Option<[u8; 32]>, -} - -impl StreamEndSpec { - pub fn new(stream_id: [u8; 16], role: StreamRole, signer: Option<[u8; 32]>) -> Self { - Self { - stream_id, - role, - signer, - } - } -} - -fn stream_end_value(spec: &StreamEndSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - let value = Value::Map(base("stream_end", 0, frame_id, sent_at_ms)) - .with_field("stream_id", Value::Bytes(spec.stream_id.to_vec())) - .with_field("role", Value::text(spec.role.name())); - with_optional_signer(value, spec.signer) -} - -/// Build a STREAM_END frame with a fresh `frame_id`/`sent_at_ms`. -pub fn stream_end(spec: &StreamEndSpec) -> Value { - stream_end_value(spec, fresh_frame_id(), current_millis()) -} - -/// Fields for a STREAM_ERROR frame — the explicit abort a well-behaved -/// peer sends instead of just dropping the stream on any non-normal -/// termination (`plans/PLAN_WIRE_PROTOCOL.md` §13.1, point 4). `code` -/// here is a free-form label (`is_binary(Code)` in the reference), NOT -/// a BOLT#4 numeric code like an ERROR (§6.4) frame's `code` — streaming -/// aborts and unary-call errors use unrelated error vocabularies. -/// `signer`: see [`StreamDataSpec::signer`]'s doc — same field, same -/// reasoning. -#[derive(Debug, Clone)] -pub struct StreamErrorSpec { - pub stream_id: [u8; 16], - pub code: String, - pub message: String, - pub signer: Option<[u8; 32]>, -} - -impl StreamErrorSpec { - pub fn new( - stream_id: [u8; 16], - code: impl Into, - message: impl Into, - signer: Option<[u8; 32]>, - ) -> Self { - Self { - stream_id, - code: code.into(), - message: message.into(), - signer, - } - } -} - -fn stream_error_value(spec: &StreamErrorSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - let value = Value::Map(base("stream_error", 0, frame_id, sent_at_ms)) - .with_field("stream_id", Value::Bytes(spec.stream_id.to_vec())) - .with_field("code", Value::Bytes(spec.code.as_bytes().to_vec())) - .with_field("message", Value::Bytes(spec.message.as_bytes().to_vec())); - with_optional_signer(value, spec.signer) -} - -/// Build a STREAM_ERROR frame with a fresh `frame_id`/`sent_at_ms`. -pub fn stream_error(spec: &StreamErrorSpec) -> Value { - stream_error_value(spec, fresh_frame_id(), current_millis()) -} - -/// Fields for a STREAM_REPLY frame — the terminal result of a -/// `client_stream`/`bidi` exchange, sent once by the provider after it -/// has fully consumed and verified whatever the caller streamed. -#[derive(Debug, Clone)] -pub struct StreamReplySpec { - pub stream_id: [u8; 16], - pub payload: Value, - pub responded_by: [u8; 32], -} - -impl StreamReplySpec { - pub fn new(stream_id: [u8; 16], payload: Value, responded_by: [u8; 32]) -> Self { - Self { - stream_id, - payload, - responded_by, - } - } -} - -fn stream_reply_value(spec: &StreamReplySpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - Value::Map(base("stream_reply", 0, frame_id, sent_at_ms)) - .with_field("stream_id", Value::Bytes(spec.stream_id.to_vec())) - .with_field("payload", spec.payload.clone()) - .with_field("responded_by", Value::Bytes(spec.responded_by.to_vec())) -} - -/// Build a STREAM_REPLY frame with a fresh `frame_id`/`sent_at_ms`. -pub fn stream_reply(spec: &StreamReplySpec) -> Value { - stream_reply_value(spec, fresh_frame_id(), current_millis()) -} - -/// Extract this frame's `stream_id`, regardless of frame type — used to -/// correlate STREAM_DATA/STREAM_END/STREAM_ERROR/STREAM_REPLY frames -/// back to the STREAM_OPEN that started the exchange. 16 bytes, matching -/// `stream_id() :: <<_:128>>`. -pub fn frame_stream_id(frame: &Value) -> Option<[u8; 16]> { - match frame.get("stream_id") { - Some(Value::Bytes(b)) => b.as_slice().try_into().ok(), - _ => None, - } -} - -/// What a stream consumer actually receives — one parsed -/// STREAM_DATA/STREAM_END/STREAM_ERROR/STREAM_REPLY frame. -#[derive(Debug, Clone)] -pub enum StreamEvent { - Data { - stream_id: [u8; 16], - seq: u64, - encoding: StreamEncoding, - body: Value, - }, - End { - stream_id: [u8; 16], - role: StreamRole, - }, - Error { - stream_id: [u8; 16], - code: String, - message: String, - }, - Reply { - stream_id: [u8; 16], - payload: Value, - responded_by: [u8; 32], - }, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum ParseStreamEventError { - NotAStreamFrame, - MissingField(&'static str), - WrongFieldType(&'static str), -} - -impl std::fmt::Display for ParseStreamEventError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ParseStreamEventError::NotAStreamFrame => write!( - f, - "frame_type is none of stream_data/stream_end/stream_error/stream_reply" - ), - ParseStreamEventError::MissingField(name) => { - write!(f, "missing required field {name:?}") - } - ParseStreamEventError::WrongFieldType(name) => { - write!(f, "field {name:?} has the wrong type") - } - } - } -} - -impl std::error::Error for ParseStreamEventError {} - -/// Parse a decoded frame as one of STREAM_DATA/STREAM_END/STREAM_ERROR/ -/// STREAM_REPLY. -pub fn parse_stream_event(frame: &Value) -> Result { - let stream_id = match frame.get("stream_id") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseStreamEventError::WrongFieldType("stream_id"))?, - Some(_) => return Err(ParseStreamEventError::WrongFieldType("stream_id")), - None => return Err(ParseStreamEventError::MissingField("stream_id")), - }; - match frame.get("frame_type") { - Some(Value::Text(t)) if t == "stream_data" => { - let seq = match frame.get("seq") { - Some(Value::Int(n)) if *n >= 0 => *n as u64, - Some(_) => return Err(ParseStreamEventError::WrongFieldType("seq")), - None => return Err(ParseStreamEventError::MissingField("seq")), - }; - let encoding = match frame.get("encoding") { - Some(Value::Text(t)) => StreamEncoding::from_name(t) - .ok_or(ParseStreamEventError::WrongFieldType("encoding"))?, - Some(_) => return Err(ParseStreamEventError::WrongFieldType("encoding")), - None => return Err(ParseStreamEventError::MissingField("encoding")), - }; - let body = frame - .get("body") - .cloned() - .ok_or(ParseStreamEventError::MissingField("body"))?; - Ok(StreamEvent::Data { - stream_id, - seq, - encoding, - body, - }) - } - Some(Value::Text(t)) if t == "stream_end" => { - let role = match frame.get("role") { - Some(Value::Text(t)) => { - StreamRole::from_name(t).ok_or(ParseStreamEventError::WrongFieldType("role"))? - } - Some(_) => return Err(ParseStreamEventError::WrongFieldType("role")), - None => return Err(ParseStreamEventError::MissingField("role")), - }; - Ok(StreamEvent::End { stream_id, role }) - } - Some(Value::Text(t)) if t == "stream_error" => { - let code = match frame.get("code") { - Some(Value::Bytes(b)) => String::from_utf8(b.clone()) - .map_err(|_| ParseStreamEventError::WrongFieldType("code"))?, - Some(_) => return Err(ParseStreamEventError::WrongFieldType("code")), - None => return Err(ParseStreamEventError::MissingField("code")), - }; - let message = match frame.get("message") { - Some(Value::Bytes(b)) => String::from_utf8(b.clone()) - .map_err(|_| ParseStreamEventError::WrongFieldType("message"))?, - Some(_) => return Err(ParseStreamEventError::WrongFieldType("message")), - None => return Err(ParseStreamEventError::MissingField("message")), - }; - Ok(StreamEvent::Error { - stream_id, - code, - message, - }) - } - Some(Value::Text(t)) if t == "stream_reply" => { - let payload = frame - .get("payload") - .cloned() - .ok_or(ParseStreamEventError::MissingField("payload"))?; - let responded_by = match frame.get("responded_by") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseStreamEventError::WrongFieldType("responded_by"))?, - Some(_) => return Err(ParseStreamEventError::WrongFieldType("responded_by")), - None => return Err(ParseStreamEventError::MissingField("responded_by")), - }; - Ok(StreamEvent::Reply { - stream_id, - payload, - responded_by, - }) - } - Some(_) | None => Err(ParseStreamEventError::NotAStreamFrame), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn hex_bytes(s: &str) -> Vec { - ::hex::decode(s).expect("valid hex fixture") - } - - fn fixed_array(hex_str: &str) -> [u8; 32] { - hex_bytes(hex_str).try_into().expect("32-byte fixture") - } - - // Same identity/evidence vectors as src/identity.rs's tests — - // captured from the same real `rebar3 shell` session. - const VECTOR_PUB: &str = "B966A9812649C3D5542FF54954FE090C43FDA6574FE48A0DD326626CFAD29A83"; - const VECTOR_PRIV: &str = "457F45FF5A09E172ED15CB20D6CB26B51AD15ED7308C12D478E8631F9CA03D4F"; - const VECTOR_PUZZLE_EVIDENCE: &str = - "09D48C91CB46513ED2580BDCEA87C40DA508D4E50EC3DF2F701AFC55D1C5C0B2"; - const VECTOR_FRAME_ID: &str = "0192E8B0F1A47000A1B2C3D4E5F60718"; - const VECTOR_SENT_AT_MS: u64 = 1_700_000_000_000; - const VECTOR_SIGNATURE: &str = "CF6959A61A2F4D2046F0124C1DD56A6541265F36A24CB18CA8C45C95031854D6AECE5FB93E2AE7BA6C444A09C7C5DED195B6EB0D1CC8E487CCF6E4F0D903B409"; - const VECTOR_ENCODED_LEN: usize = 375; - - /// The single strongest test in this crate so far: builds the exact - /// same CONNECT frame `macula_frame:connect/1` + `sign/2` produced - /// in a real, live `rebar3 shell` (same identity, fixed - /// `frame_id`/`sent_at_ms` injected explicitly since the reference - /// randomizes both per call), and checks the encoded bytes — - /// including the Ed25519 signature — match exactly. See this - /// module's doc comment. - #[test] - fn connect_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = KeyPair::from_seed_bytes(fixed_array(VECTOR_PRIV)); - let puzzle_evidence = fixed_array(VECTOR_PUZZLE_EVIDENCE); - let frame_id: [u8; 16] = hex_bytes(VECTOR_FRAME_ID).try_into().expect("16 bytes"); - - let spec = ConnectSpec::new(pub_bytes, puzzle_evidence); - let unsigned = connect_value(&spec, frame_id, VECTOR_SENT_AT_MS); - let signed = sign(unsigned, &identity); - - let sig_field = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!( - hex::encode_upper(&sig_field), - VECTOR_SIGNATURE, - "signature diverged from the reference — canonical CBOR encoding \ - or the signing domain/bytes must differ somewhere" - ); - - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), VECTOR_ENCODED_LEN); - - // Round-trip: decode what we just built and verify it against - // the known pubkey, exactly like a receiving station would. - let decoded = match decode(&encoded).expect("valid frame") { - Decoded::Frame(value, consumed) => { - assert_eq!(consumed, encoded.len()); - value - } - Decoded::More(n) => panic!("unexpectedly needed {n} more bytes"), - }; - verify(&decoded, &pub_bytes).expect("our own signature must verify"); - } - - #[test] - fn verify_rejects_a_tampered_field() { - let identity = KeyPair::from_seed_bytes(fixed_array(VECTOR_PRIV)); - let pub_bytes = identity.public_bytes(); - let spec = ConnectSpec::new(pub_bytes, fixed_array(VECTOR_PUZZLE_EVIDENCE)); - let signed = sign(connect(&spec), &identity); - - // Flip the capabilities field after signing. - let tampered = signed.with_field("capabilities", Value::Int(999)); - assert_eq!( - verify(&tampered, &pub_bytes), - Err(VerifyError::SignatureInvalid) - ); - } - - #[test] - fn verify_rejects_a_missing_signature() { - let frame = Value::Map(vec![(Value::text("frame_type"), Value::text("connect"))]); - let pubkey = [0u8; 32]; - assert_eq!(verify(&frame, &pubkey), Err(VerifyError::MissingSignature)); - } - - #[test] - fn decode_reports_more_for_a_short_buffer() { - assert!(matches!(decode(&[0, 0]), Ok(Decoded::More(2)))); - // A 4-byte length prefix claiming 10 bytes of payload, but only - // 2 are present. - let mut buf = 10u32.to_be_bytes().to_vec(); - buf.extend_from_slice(&[0, 0]); - assert!(matches!(decode(&buf), Ok(Decoded::More(8)))); - } - - #[test] - fn decode_rejects_a_length_over_the_cap() { - let buf = ((MAX_FRAME_BYTES as u32) + 1).to_be_bytes(); - assert!(matches!( - decode(&buf), - Err(DecodeFrameError::TooLarge(n)) if n == MAX_FRAME_BYTES + 1 - )); - } - - #[test] - fn goodbye_frame_round_trips() { - let frame = goodbye("normal", Some("bye")); - assert_eq!(frame.get("frame_type"), Some(&Value::text("goodbye"))); - assert_eq!(frame.get("reason"), Some(&Value::text("normal"))); - assert_eq!(frame.get("detail"), Some(&Value::Bytes(b"bye".to_vec()))); - } - - #[test] - fn goodbye_without_detail_is_null() { - let frame = goodbye("timeout", None); - assert_eq!(frame.get("detail"), Some(&Value::Null)); - } - - #[test] - fn parse_hello_reads_a_well_formed_frame() { - let node_id = [7u8; 32]; - let station_id = [8u8; 32]; - let realm = [9u8; 32]; - let hello = Value::Map(vec![ - (Value::text("frame_type"), Value::text("hello")), - (Value::text("node_id"), Value::Bytes(node_id.to_vec())), - (Value::text("station_id"), Value::Bytes(station_id.to_vec())), - ( - Value::text("realms"), - Value::List(vec![Value::Bytes(realm.to_vec())]), - ), - (Value::text("capabilities"), Value::Int(0)), - (Value::text("accepted"), Value::text("true")), - (Value::text("negotiated_capabilities"), Value::Int(3)), - ]); - let info = parse_hello(&hello).expect("well-formed hello"); - assert_eq!(info.node_id, node_id); - assert_eq!(info.station_id, station_id); - assert_eq!(info.realms, vec![realm]); - assert!(info.accepted); - assert_eq!(info.negotiated_capabilities, 3); - assert_eq!(info.refusal_code, None); - } - - #[test] - fn parse_hello_rejects_the_wrong_frame_type() { - let frame = Value::Map(vec![(Value::text("frame_type"), Value::text("connect"))]); - assert_eq!(parse_hello(&frame), Err(ParseHelloError::NotAHelloFrame)); - } - - #[test] - fn parse_hello_reports_a_missing_field() { - let frame = Value::Map(vec![(Value::text("frame_type"), Value::text("hello"))]); - assert_eq!( - parse_hello(&frame), - Err(ParseHelloError::MissingField("node_id")) - ); - } - - // ------------------------------------------------------------- - // Differential vectors for CALL/RESULT/ERROR/PUBLISH/SUBSCRIBE/ - // UNSUBSCRIBE/EVENT — same method and same identity as the CONNECT - // vector above: built with fixed frame_id/sent_at_ms in a real - // `rebar3 shell`, exact encoded bytes (including the Ed25519 - // signature) asserted to match. The CALL vector specifically caught - // a real discrepancy on the first attempt — a hand-built test frame - // that assumed `source_route` stayed `null` like other optional - // fields, when the real constructor always sets it to an empty - // binary — fixed before this test was written, not after. - // ------------------------------------------------------------- - - const VECTOR_CALL_ID: &str = "AABBCCDDEEFF00112233445566778899"; - const VECTOR_ZERO_REALM: [u8; 32] = [0u8; 32]; - - fn vector_identity() -> KeyPair { - KeyPair::from_seed_bytes(fixed_array(VECTOR_PRIV)) - } - - fn vector_call_id() -> [u8; 16] { - hex_bytes(VECTOR_CALL_ID).try_into().expect("16 bytes") - } - - fn vector_frame_id() -> [u8; 16] { - hex_bytes(VECTOR_FRAME_ID).try_into().expect("16 bytes") - } - - #[test] - fn call_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = CallSpec::new( - vector_call_id(), - "_content.get_manifest", - VECTOR_ZERO_REALM, - Value::Map(vec![(Value::text("hello"), Value::text("world"))]), - 1_700_000_030_000, - pub_bytes, - ); - let signed = sign( - call_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!( - hex::encode_upper(&sig), - "A6BC174F0241E644F634702C08781C8FC8BD3CDE3CA9650DE8A731A01203D9B9403A2CAD75800F7B8C9AAE16FA146B1195FF03F0E6DC4595A652D7F29BFE350A" - ); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 386); - } - - #[test] - fn result_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = ResultSpec::new(vector_call_id(), Value::text("ok-result"), pub_bytes); - let signed = sign( - result_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!( - hex::encode_upper(&sig), - "03E8F72D51D958C318B7F1C25D78408408317DEAB23434D6EA32F211CADEA1C62900DA15AFF603E795B19A388D382BDB10E65AEFC6F0CE551270AB172A88E50B" - ); - assert_eq!(encode(&signed).expect("encodable").len(), 301); - } - - #[test] - fn error_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = CallErrorSpec::new( - vector_call_id(), - crate::bolt4::Code::UnknownNextPeer, - pub_bytes, - ); - let signed = sign( - call_error_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!( - hex::encode_upper(&sig), - "182ECD5217CE378F576635B23CC8C9F265555142845D6CBA033A282BAED97966C23FBE91D08507FB8E840375AA17665763804F40F89102F8D3EDAD4DA98FC20D" - ); - assert_eq!(encode(&signed).expect("encodable").len(), 333); - } - - #[test] - fn publish_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = PublishSpec::new( - "test.topic", - VECTOR_ZERO_REALM, - pub_bytes, - 42, - Value::text("published-data"), - VECTOR_SENT_AT_MS, - ); - let signed = sign( - publish_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!( - hex::encode_upper(&sig), - "DD49D10EFA9F2EED0A393DC02DC5BBAC25D6731562EA39F5AB2E5337824527AFFBC7D917AF4DE5EFDBE5BC41E58659E05EC6FDE4E91FB1A32CC9C211456DF10C" - ); - assert_eq!(encode(&signed).expect("encodable").len(), 355); - } - - // Reference vector generated directly from the Erlang implementation - // (macula-io/macula, src/peering/macula_frame.erl:sign_publisher/2), - // live in a rebar3 shell against the same fixed identity every other - // vector test in this file uses. First publisher_sig implementation - // in any repo as of 2026-08-29 (macula-go, macula-rust, - // macula-dotnet all lacked it) -- no prior port existed to - // cross-check against instead, so this is checked straight against - // the Erlang source of truth. - #[test] - fn publisher_sig_matches_the_erlang_reference() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = PublishSpec::new( - "acme/svc.do", - VECTOR_ZERO_REALM, - pub_bytes, - 42, - Value::Bytes(b"hello".to_vec()), - VECTOR_SENT_AT_MS, - ); - let unsigned = publish_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS); - let with_pub_sig = sign_publisher(unsigned, &identity); - - let sig = match with_pub_sig.get("publisher_sig") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a publisher_sig field, got {other:?}"), - }; - assert_eq!( - hex::encode_upper(&sig), - "C11BEB676A590FD1BA86F0B77E377B4582AA461DB1283F64E57224E920A7BD0A2C7D36271B795FFC3CB4F2C7BB8925B034431AA6425E25B2AEEFAC026883BB0C" - ); - - verify_publisher(&with_pub_sig).expect("our own freshly-signed frame must verify"); - - // Tamper check: changing payload after signing must invalidate it. - let tampered = with_pub_sig - .clone() - .with_field("payload", Value::Bytes(b"world".to_vec())); - assert!( - verify_publisher(&tampered).is_err(), - "verify_publisher accepted a frame with a tampered payload" - ); - - // Absence must be a verification failure, not "trusted". - assert_eq!( - verify_publisher(&unsigned_publish_for_tamper_check(&spec)), - Err(VerifyPublisherError::MissingPublisherSig) - ); - } - - fn unsigned_publish_for_tamper_check(spec: &PublishSpec) -> Value { - publish_value(spec, vector_frame_id(), VECTOR_SENT_AT_MS) - } - - // Full encode/decode round trip with BOTH publisher_sig and the - // per-hop signature present, mirroring exactly what a real caller - // (macula-go's connection.Session.Publish does this already; - // this crate's own connection layer should too) would build. - #[test] - fn publish_frame_with_both_signatures_round_trips() { - let identity = KeyPair::generate(); - let pub_bytes = identity.node_id(); - let spec = PublishSpec::new( - "acme/svc.do", - VECTOR_ZERO_REALM, - pub_bytes, - 1, - Value::Bytes(b"hello".to_vec()), - VECTOR_SENT_AT_MS, - ); - let unsigned = publish(&spec); - let with_pub_sig = sign_publisher(unsigned, &identity); - let fully_signed = sign(with_pub_sig, &identity); - - let encoded = encode(&fully_signed).expect("encodable"); - let decoded = match decode(&encoded).expect("decodable") { - Decoded::Frame(value, consumed) => { - assert_eq!(consumed, encoded.len()); - value - } - Decoded::More(n) => panic!("unexpectedly needed {n} more bytes"), - }; - - verify(&decoded, &pub_bytes).expect("per-hop verify on decoded frame"); - verify_publisher(&decoded).expect("verify_publisher on decoded frame"); - - assert!( - decoded.get("publisher_sig").is_some(), - "decoded frame lost publisher_sig" - ); - assert!( - decoded.get("signature").is_some(), - "decoded frame lost signature" - ); - } - - #[test] - fn subscribe_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = SubscribeSpec::new("test.topic", VECTOR_ZERO_REALM, pub_bytes); - let signed = sign( - subscribe_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!( - hex::encode_upper(&sig), - "ABDD7304B887A53B149CE4D4C62F1AFD20AE07D8612B76F22006FA6676B8DDB37C1D5106358D32080246BA4355A9E04BF49F73600E752F5F9037D7A93A47020A" - ); - assert_eq!(encode(&signed).expect("encodable").len(), 313); - } - - #[test] - fn unsubscribe_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = UnsubscribeSpec::new("test.topic", VECTOR_ZERO_REALM, pub_bytes); - let signed = sign( - unsubscribe_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!( - hex::encode_upper(&sig), - "C917068BE4E1C5A3C753F249037DD8F44293D888BB252BF1E828671969547969982160C91A0E3CA1C31DE29ED39E3677E7F20F4BDE61539D4618B3703018E403" - ); - assert_eq!(encode(&signed).expect("encodable").len(), 298); - } - - #[test] - fn event_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let fields = base("event", 0, vector_frame_id(), VECTOR_SENT_AT_MS); - let unsigned = Value::Map(fields) - .with_field("realm", Value::Bytes(VECTOR_ZERO_REALM.to_vec())) - .with_field("topic", Value::Bytes(b"test.topic".to_vec())) - .with_field("publisher", Value::Bytes(pub_bytes.to_vec())) - .with_field("seq", Value::Int(42)) - .with_field("payload", Value::text("published-data")) - .with_field("delivered_via", Value::text("direct")); - let signed = sign(unsigned, &identity); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!( - hex::encode_upper(&sig), - "9B9EE4EAC375FBD0C9B5A5BC6D82E35739F8ECBF594979891BF35E5BDB53A148B3936AF99217C3D8C12E2EEA0686F68D5FE63284BE6B142F87BFF319DDDB780F" - ); - assert_eq!(encode(&signed).expect("encodable").len(), 341); - - // Round-trip through parse_event too, since EVENT (unlike the - // others above) has a real parser a receiving client uses. - let decoded = decode(&encode(&signed).unwrap()).unwrap(); - let Decoded::Frame(value, _) = decoded else { - panic!("expected a complete frame") - }; - let info = parse_event(&value).expect("well-formed event"); - assert_eq!(info.topic, "test.topic"); - assert_eq!(info.seq, 42); - assert_eq!(info.delivered_via, "direct"); - } - - #[test] - fn parse_call_response_reads_a_result() { - let frame = Value::Map(vec![ - (Value::text("frame_type"), Value::text("result")), - (Value::text("call_id"), Value::Bytes(vec![1; 16])), - (Value::text("payload"), Value::text("ok")), - (Value::text("responded_by"), Value::Bytes(vec![2; 32])), - ]); - match parse_call_response(&frame).expect("well-formed result") { - CallResponse::Result { - payload, - responded_by, - } => { - assert_eq!(payload, Value::text("ok")); - assert_eq!(responded_by, [2u8; 32]); - } - other => panic!("expected Result, got {other:?}"), - } - } - - #[test] - fn parse_call_response_reads_an_error() { - let frame = Value::Map(vec![ - (Value::text("frame_type"), Value::text("error")), - (Value::text("call_id"), Value::Bytes(vec![1; 16])), - (Value::text("code"), Value::Int(1)), - (Value::text("name"), Value::text("unknown_next_peer")), - (Value::text("reported_by"), Value::Bytes(vec![2; 32])), - (Value::text("detail"), Value::Null), - ]); - match parse_call_response(&frame).expect("well-formed error") { - CallResponse::Error { - code, - name, - reported_by, - detail, - } => { - assert_eq!(code, 1); - assert_eq!(name, "unknown_next_peer"); - assert_eq!(reported_by, [2u8; 32]); - assert_eq!(detail, None); - } - other => panic!("expected Error, got {other:?}"), - } - } - - #[test] - fn frame_call_id_reads_from_any_frame_type() { - let frame = Value::Map(vec![(Value::text("call_id"), Value::Bytes(vec![9; 16]))]); - assert_eq!(frame_call_id(&frame), Some([9u8; 16])); - // A 32-byte value (e.g. a pubkey accidentally in this field) must - // NOT be accepted as a 16-byte call_id. - let wrong_size = Value::Map(vec![(Value::text("call_id"), Value::Bytes(vec![9; 32]))]); - assert_eq!(frame_call_id(&wrong_size), None); - } - - // ------------------------------------------------------------- - // Streaming RPC (§13) — same differential method as CALL above, - // vectors captured from a real `macula_frame:stream_open/1` + - // `stream_data/1` + `stream_end/1` + `stream_error/1` + - // `stream_reply/1` + `sign/2` in a live `rebar3 shell` session - // against the same identity/frame_id/sent_at_ms fixtures already - // defined above. - // ------------------------------------------------------------- - - const VECTOR_STREAM_ID: &str = "0102030405060708090A0B0C0D0E0F10"; - - fn vector_stream_id() -> [u8; 16] { - hex_bytes(VECTOR_STREAM_ID).try_into().expect("16 bytes") - } - - #[test] - fn stream_open_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = StreamOpenSpec::new( - vector_stream_id(), - "macula_rust_sdk.test_stream", - VECTOR_ZERO_REALM, - StreamMode::ClientStream, - Value::Map(vec![(Value::text("hello"), Value::text("world"))]), - 1_700_000_030_000, - pub_bytes, - ); - let signed = sign( - stream_open_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "6070D8AB71F837591AC2C803C04F9E1D3FA01C9310D33C96A90434820C5E50550F9DEA8A764247EB49AF63447C037E192B7892A365C1A4ACB9BC46B98AA5670F"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 415); - } - - /// `parse_stream_open` round-tripped against the SAME - /// already-byte-verified construction above: since - /// `stream_open_frame_matches_the_reference_byte_for_byte` already - /// proves the constructor's encoding is bit-for-bit correct, - /// getting the same field values back out here proves the parser - /// inverts it correctly too, without needing a second live vector. - #[test] - fn parse_stream_open_round_trips_a_well_formed_frame() { - let pub_bytes = fixed_array(VECTOR_PUB); - let spec = StreamOpenSpec::new( - vector_stream_id(), - "macula_rust_sdk.test_stream", - VECTOR_ZERO_REALM, - StreamMode::ClientStream, - Value::Map(vec![(Value::text("hello"), Value::text("world"))]), - 1_700_000_030_000, - pub_bytes, - ); - let frame = stream_open_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS); - let info = parse_stream_open(&frame).expect("well-formed stream_open"); - assert_eq!(info.stream_id, vector_stream_id()); - assert_eq!(info.procedure, "macula_rust_sdk.test_stream"); - assert_eq!(info.realm, VECTOR_ZERO_REALM); - assert_eq!(info.mode, StreamMode::ClientStream); - assert_eq!( - info.args, - Value::Map(vec![(Value::text("hello"), Value::text("world"))]) - ); - assert_eq!(info.deadline_ms, 1_700_000_030_000); - assert_eq!(info.caller, pub_bytes); - } - - #[test] - fn parse_stream_open_rejects_the_wrong_frame_type() { - let frame = Value::Map(vec![( - Value::text("frame_type"), - Value::text("stream_data"), - )]); - assert_eq!( - parse_stream_open(&frame).unwrap_err(), - ParseStreamOpenError::NotAStreamOpenFrame - ); - } - - #[test] - fn stream_data_raw_frame_matches_the_reference_byte_for_byte() { - let identity = vector_identity(); - let spec = StreamDataSpec::new( - vector_stream_id(), - 0, - StreamEncoding::Raw, - Value::Bytes(b"raw chunk bytes".to_vec()), - None, - ); - let signed = sign( - stream_data_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "35770744FE5BD01B86DDA01AB4EF855E4E4FE0EDFEDC89FF690728C585C60A5CB035717E3EA9133C4AD833E226F4DB95E9A5AF9AC59E7BACBB8BDF72611F8003"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 269); - } - - /// The vector this crate was missing until 2026-08-29: `signer` - /// present, matching what every real `StreamHandle` call site now - /// sends. Generated live against `macula_frame:stream_data/1` with - /// `signer => Pub` in the spec map (`rebar3 shell`, same identity/ - /// frame_id/stream_id/sent_at_ms fixture as every other vector in - /// this module) — not guessed from the field's shape. - #[test] - fn stream_data_with_signer_matches_the_reference_byte_for_byte() { - let identity = vector_identity(); - let spec = StreamDataSpec::new( - vector_stream_id(), - 0, - StreamEncoding::Raw, - Value::Bytes(b"raw chunk bytes".to_vec()), - Some(fixed_array(VECTOR_PUB)), - ); - let signed = sign( - stream_data_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "3EA0B6B6DB1549D2EA42AF015A477FCD6D00B11F48F9CC07AF0914CAC18F22B5C12E5EE446811388F207D688960B67D9BEE7B4D998BE02F2B1426B6C4A06D307"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 310); - } - - /// The real point of this vector: `encoding = msgpack` with a - /// structured `body` (`{a: 1, greeting: "hi"}`, mirroring the - /// reference's `#{a => 1, greeting => <<"hi">>}`) still matches the - /// reference's signature byte-for-byte — proving `body` is encoded - /// as an ordinary nested CBOR value in the frame's own envelope, not - /// pre-serialized through a separate msgpack codec this crate would - /// otherwise need to implement. See this section's module doc. - #[test] - fn stream_data_msgpack_frame_matches_the_reference_byte_for_byte() { - let identity = vector_identity(); - let spec = StreamDataSpec::new( - vector_stream_id(), - 1, - StreamEncoding::Msgpack, - Value::Map(vec![ - (Value::text("a"), Value::Int(1)), - // `greeting`'s VALUE is a binary (`<<"hi">>`) in the - // reference, not an atom -- bytes, not text, unlike its - // (atom) key. - (Value::text("greeting"), Value::Bytes(b"hi".to_vec())), - ]), - None, - ); - let signed = sign( - stream_data_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "99CA90B0C01FD349DBAF317D03872E5F460426789874D79B6FBE37F4AC92C2AD690A00CDB3734F262D5C58C8F3BFD06F8AE892A8B5655274718A283ABA1D4D08"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 273); - } - - #[test] - fn stream_end_frame_matches_the_reference_byte_for_byte() { - let identity = vector_identity(); - let spec = StreamEndSpec::new(vector_stream_id(), StreamRole::Send, None); - let signed = sign( - stream_end_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "78F2B94BD5AC70901EABB31D8B17C89B58A88942300C6232545899AFB933B2C4B7399BB183A5660671981B6346DA27033C8F93A99E7EBA96F0F689B03D4F940A"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 239); - } - - /// See `stream_data_with_signer_matches_the_reference_byte_for_byte`'s - /// doc — same fixture, same 2026-08-29 gap, this crate's STREAM_END. - #[test] - fn stream_end_with_signer_matches_the_reference_byte_for_byte() { - let identity = vector_identity(); - let spec = StreamEndSpec::new( - vector_stream_id(), - StreamRole::Send, - Some(fixed_array(VECTOR_PUB)), - ); - let signed = sign( - stream_end_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "CC316B0A1C1AD4701AD16D8A140ED62D5DEEFD721C1CEB574CC8755C645CA27413EF9C6A6A9C4768564524C412515C14637A9D6BD4CCB8CD1ADD44F2A240C70C"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 280); - } - - #[test] - fn stream_error_frame_matches_the_reference_byte_for_byte() { - let identity = vector_identity(); - let spec = StreamErrorSpec::new(vector_stream_id(), "cancelled", "boom", None); - let signed = sign( - stream_error_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "119F379518EC17C603ED5466A57D7AE53198A8AC4D5CA9849934A78994428CB3DAD40BC0EFECE1A0C8EEB0ACC28973C0F7E55DE6444827091814AF0715D9FF0B"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 259); - } - - /// See `stream_data_with_signer_matches_the_reference_byte_for_byte`'s - /// doc — same fixture, same 2026-08-29 gap, this crate's STREAM_ERROR. - #[test] - fn stream_error_with_signer_matches_the_reference_byte_for_byte() { - let identity = vector_identity(); - let spec = StreamErrorSpec::new( - vector_stream_id(), - "cancelled", - "boom", - Some(fixed_array(VECTOR_PUB)), - ); - let signed = sign( - stream_error_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "223062E2816C5E6DABCF08A0A4FD01F477F2D1D933F2F1FDC971CAB570003DDE8192CC2F8811CE4A2D180B6781AFA64EB4057947E25CF121F745A9654DC23D0A"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 300); - } - - #[test] - fn stream_reply_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = StreamReplySpec::new( - vector_stream_id(), - Value::Map(vec![(Value::text("ok"), Value::text("true"))]), - pub_bytes, - ); - let signed = sign( - stream_reply_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "ADF57AD58B253F175ADF72E4717E078C62F3E22CBDDBF8DDC0DD8A47CAAA061E8A37C73BAAB91E450D1D8472021B6A0161169D77E9D186C436D3E6580D48C703"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 295); - } - - #[test] - fn frame_stream_id_reads_from_any_frame_type() { - let frame = Value::Map(vec![(Value::text("stream_id"), Value::Bytes(vec![9; 16]))]); - assert_eq!(frame_stream_id(&frame), Some([9u8; 16])); - let wrong_size = Value::Map(vec![(Value::text("stream_id"), Value::Bytes(vec![9; 32]))]); - assert_eq!(frame_stream_id(&wrong_size), None); - } - - #[test] - fn parse_stream_event_reads_data_end_error_and_reply() { - let data = Value::Map(vec![ - (Value::text("frame_type"), Value::text("stream_data")), - (Value::text("stream_id"), Value::Bytes(vec![1; 16])), - (Value::text("seq"), Value::Int(3)), - (Value::text("encoding"), Value::text("raw")), - (Value::text("body"), Value::Bytes(b"hi".to_vec())), - ]); - match parse_stream_event(&data).expect("well-formed stream_data") { - StreamEvent::Data { - stream_id, - seq, - encoding, - body, - } => { - assert_eq!(stream_id, [1u8; 16]); - assert_eq!(seq, 3); - assert_eq!(encoding, StreamEncoding::Raw); - assert_eq!(body, Value::Bytes(b"hi".to_vec())); - } - other => panic!("expected Data, got {other:?}"), - } - - let end = Value::Map(vec![ - (Value::text("frame_type"), Value::text("stream_end")), - (Value::text("stream_id"), Value::Bytes(vec![1; 16])), - (Value::text("role"), Value::text("both")), - ]); - match parse_stream_event(&end).expect("well-formed stream_end") { - StreamEvent::End { stream_id, role } => { - assert_eq!(stream_id, [1u8; 16]); - assert_eq!(role, StreamRole::Both); - } - other => panic!("expected End, got {other:?}"), - } - - let error = Value::Map(vec![ - (Value::text("frame_type"), Value::text("stream_error")), - (Value::text("stream_id"), Value::Bytes(vec![1; 16])), - (Value::text("code"), Value::Bytes(b"cancelled".to_vec())), - (Value::text("message"), Value::Bytes(b"boom".to_vec())), - ]); - match parse_stream_event(&error).expect("well-formed stream_error") { - StreamEvent::Error { - stream_id, - code, - message, - } => { - assert_eq!(stream_id, [1u8; 16]); - assert_eq!(code, "cancelled"); - assert_eq!(message, "boom"); - } - other => panic!("expected Error, got {other:?}"), - } - - let reply = Value::Map(vec![ - (Value::text("frame_type"), Value::text("stream_reply")), - (Value::text("stream_id"), Value::Bytes(vec![1; 16])), - (Value::text("payload"), Value::text("done")), - (Value::text("responded_by"), Value::Bytes(vec![2; 32])), - ]); - match parse_stream_event(&reply).expect("well-formed stream_reply") { - StreamEvent::Reply { - stream_id, - payload, - responded_by, - } => { - assert_eq!(stream_id, [1u8; 16]); - assert_eq!(payload, Value::text("done")); - assert_eq!(responded_by, [2u8; 32]); - } - other => panic!("expected Reply, got {other:?}"), - } - } - - #[test] - fn parse_stream_event_rejects_a_non_stream_frame() { - let frame = Value::Map(vec![ - (Value::text("frame_type"), Value::text("call")), - (Value::text("stream_id"), Value::Bytes(vec![1; 16])), - ]); - assert_eq!( - parse_stream_event(&frame).unwrap_err(), - ParseStreamEventError::NotAStreamFrame - ); - } - - // ------------------------------------------------------------- - // RPC advertise (§6.9) — same differential method, vectors - // captured from a real `macula_frame:advertise/1` + - // `unadvertise/1` + `sign/2` in a live `rebar3 shell`. - // ------------------------------------------------------------- - - #[test] - fn advertise_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = AdvertiseSpec::new( - VECTOR_ZERO_REALM, - "macula_rust_sdk.test_procedure", - pub_bytes, - ); - let signed = sign( - advertise_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "22AE051A542289279A56FB9C8587341232EF48208F9A8641C77F37E1B5D3D26A4B7C30CDCA4AE6E851FEB4E2FBF9C5B2469AFCC7317D59F5D775A05C99E99C0A"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 330); - } - - #[test] - fn unadvertise_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = UnadvertiseSpec::new( - VECTOR_ZERO_REALM, - "macula_rust_sdk.test_procedure", - pub_bytes, - ); - let signed = sign( - unadvertise_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "C4111E5C2685DCDDB035B9DA29AD2A30D90BC7CAC09620A675D9A3DB480508FDAD7DCDD145B77607395DBF6195643BBA60C2C6D29E2DCFE5F70F20CF15DA2600"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 323); - } -} diff --git a/src/identity.rs b/src/identity.rs deleted file mode 100644 index 9a3d792..0000000 --- a/src/identity.rs +++ /dev/null @@ -1,451 +0,0 @@ -//! Ed25519 identity and the S/Kademlia crypto puzzle, matching macula's -//! own `macula_identity.erl` (`macula-io/macula`). -//! -//! Uses `ed25519-dalek` (with the `rand_core` feature) — the same crate -//! macula's own `macula_crypto_nif` Rust NIF already wraps in production, -//! not a separate crypto implementation, though the two are not required -//! to track the same `ed25519-dalek` version: Ed25519 signing is -//! deterministic per RFC 8032, so a byte-identical seed/message pair must -//! produce a byte-identical signature across any correct implementation, -//! any version. Every keypair/sign/verify test in this module is checked -//! against fixtures captured directly from the real `crypto:generate_key/2` -//! and `crypto:sign/4` in `macula-io/macula`'s own `rebar3 shell`, not just -//! hand-derived expectations — which is exactly what lets this crate move -//! ahead of the NIF's own `ed25519-dalek` pin without losing that proof. -//! -//! A macula NodeId **is** an Ed25519 public key (32 bytes) — there is no -//! separate account/identity layer underneath it. Identities are -//! optionally "puzzle-hardened": ground until `SHA-256(pubkey)` has at -//! least `N` leading zero bits (S/Kademlia Sybil defense — this raises -//! the cost of *minting* identities in bulk, not of connecting with one -//! that already exists). Grinding is a one-time cost paid once per -//! identity, not per connection: `puzzle_evidence` is a cheap, -//! deterministic hash computed fresh on every `CONNECT` frame, and -//! `puzzle_valid` is a cheap check, not a proof-of-work re-verification. -//! -//! **Every station checks this on every CONNECT/HELLO, for every kind of -//! dialer — this is not a station-to-station-only concern.** Skipping it -//! produces a real, previously-observed failure mode: the QUIC/TLS -//! connection reports healthy, but the station silently rejects the -//! application-layer HELLO, so the link looks connected while delivering -//! nothing. Always use [`KeyPair::generate_with_puzzle`], never -//! [`KeyPair::generate`], for any identity that will actually dial a -//! station. - -use std::fmt; -use std::fs; -use std::io; -use std::path::Path; - -use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey}; -use sha2::{Digest, Sha256}; - -use crate::keystore::{KeyStore, KeyStoreError}; - -/// Matches `?DEFAULT_PUZZLE_DIFFICULTY` in `macula_identity.erl`. Grinding -/// at this difficulty is sub-millisecond — see the module doc. -pub const DEFAULT_PUZZLE_DIFFICULTY: u32 = 8; - -const KEY_FILE_MAGIC: &[u8] = b"macula-v2-key\0"; - -/// An Ed25519 keypair. The public half **is** the macula NodeId. -pub struct KeyPair { - signing_key: SigningKey, -} - -impl KeyPair { - /// Generate a fresh keypair. Does **not** grind a puzzle — the - /// resulting identity will be silently rejected by any station that - /// enforces puzzle admission (which is every station in practice). - /// Prefer [`generate_with_puzzle`](Self::generate_with_puzzle) unless - /// you specifically need an unhardened identity (e.g. a unit test - /// that never dials a real station). - /// - /// Seeded directly from the OS RNG (`rand::rngs::SysRng`), unwrapped - /// via [`rand::rand_core::UnwrapErr`] to make it panic rather than - /// return a `Result` on the rare case the OS entropy syscall itself - /// fails — the exact same fail-fast behavior `rand` 0.8's `OsRng` had - /// implicitly, since `rand` 0.9 split `SysRng` into a fallible-only - /// type that no longer satisfies `SigningKey::generate`'s infallible - /// `CryptoRng` bound on its own. This is the pattern - /// `ed25519-dalek` 3.0's own docs use for this exact call - /// (`ed25519_dalek::SigningKey::generate`'s doc example), not - /// `rand::rng()`/`ThreadRng` — a userspace CSPRNG that, since rand - /// 0.9, is explicitly documented as **not** reseeding on `fork()`, - /// which would be a real (if narrow) identity-collision risk for a - /// long-lived process that forks after generating a key. Direct OS - /// randomness has no such state to reuse across a fork. - pub fn generate() -> Self { - let signing_key = SigningKey::generate(&mut rand::rand_core::UnwrapErr(rand::rngs::SysRng)); - Self { signing_key } - } - - /// Generate a keypair, grinding fresh candidates until - /// `puzzle_valid(pubkey, difficulty)` holds. This is the one-time - /// cost described in the module doc — not something to redo per - /// connection. - pub fn generate_with_puzzle(difficulty: u32) -> Self { - loop { - let candidate = Self::generate(); - if puzzle_valid(&candidate.public_bytes(), difficulty) { - return candidate; - } - } - } - - /// As [`generate_with_puzzle`](Self::generate_with_puzzle), at - /// [`DEFAULT_PUZZLE_DIFFICULTY`]. - pub fn generate_with_default_puzzle() -> Self { - Self::generate_with_puzzle(DEFAULT_PUZZLE_DIFFICULTY) - } - - /// Reconstruct a keypair from its 32-byte seed. Deterministic — the - /// same seed always yields the same public key and, for a given - /// message, the same signature (Ed25519 per RFC 8032 has no signing - /// randomness). - pub fn from_seed_bytes(seed: [u8; 32]) -> Self { - Self { - signing_key: SigningKey::from_bytes(&seed), - } - } - - /// The public key — also this identity's macula NodeId. - pub fn public_bytes(&self) -> [u8; 32] { - self.signing_key.verifying_key().to_bytes() - } - - /// The 32-byte seed. Matches `macula_identity:private/1`. - pub fn private_bytes(&self) -> [u8; 32] { - self.signing_key.to_bytes() - } - - /// Alias for [`public_bytes`](Self::public_bytes) — NodeId == public - /// key, matching `macula_identity:node_id/1`'s own doc ("Phase 1: - /// NodeId == public key"). - pub fn node_id(&self) -> [u8; 32] { - self.public_bytes() - } - - /// Sign `msg` with this identity. Callers add their own domain - /// separation by prefixing `msg` (see the frame-signing domains in - /// `plans/PLAN_WIRE_PROTOCOL.md` §4) — this function itself is raw - /// Ed25519, matching `macula_identity:sign/2` exactly. - pub fn sign(&self, msg: &[u8]) -> [u8; 64] { - self.signing_key.sign(msg).to_bytes() - } - - /// This identity's puzzle evidence — see [`puzzle_evidence`]. - pub fn puzzle_evidence(&self) -> [u8; 32] { - puzzle_evidence(&self.public_bytes()) - } - - /// Save this keypair to `path`, atomically (write to a `.tmp` - /// sibling, then rename) with `0600` permissions on Unix — matching - /// `macula_identity:save/2`'s own file format and discipline exactly: - /// a 14-byte magic header (`"macula-v2-key\0"`), then the 32-byte - /// public key, then the 32-byte private seed. - /// - /// This raw-file format is a testing/parity convenience, matching the - /// Erlang reference. A real mobile binding should use platform - /// secure storage (Keychain on iOS, Keystore on Android) instead of - /// this file format directly — see - /// `plans/PLAN_WIRE_PROTOCOL.md`'s puzzle_evidence lifecycle note. - pub fn save(&self, path: impl AsRef) -> io::Result<()> { - let path = path.as_ref(); - let mut blob = Vec::with_capacity(KEY_FILE_MAGIC.len() + 64); - blob.extend_from_slice(KEY_FILE_MAGIC); - blob.extend_from_slice(&self.public_bytes()); - blob.extend_from_slice(&self.private_bytes()); - - let tmp_path = path.with_extension("tmp"); - fs::write(&tmp_path, &blob)?; - set_owner_only_permissions(&tmp_path)?; - fs::rename(&tmp_path, path) - } - - /// Load a keypair previously written by [`save`](Self::save). - /// Returns [`LoadKeyError::PubkeyMismatch`] if the file's stored - /// public key doesn't match the one derived from its stored private - /// key — a corrupted or hand-edited key file would otherwise - /// silently produce a keypair that can never complete a real - /// handshake, which is a much harder failure to diagnose than a - /// load-time error. - pub fn load(path: impl AsRef) -> Result { - let blob = fs::read(path.as_ref())?; - let expected_len = KEY_FILE_MAGIC.len() + 64; - if blob.len() != expected_len || !blob.starts_with(KEY_FILE_MAGIC) { - return Err(LoadKeyError::BadKeyFile); - } - let rest = &blob[KEY_FILE_MAGIC.len()..]; - let stored_pub: [u8; 32] = rest[..32].try_into().expect("checked length"); - let stored_priv: [u8; 32] = rest[32..64].try_into().expect("checked length"); - - let keypair = Self::from_seed_bytes(stored_priv); - if keypair.public_bytes() != stored_pub { - return Err(LoadKeyError::PubkeyMismatch); - } - Ok(keypair) - } - - /// Persist this keypair's seed to `store` — see `crate::keystore`'s - /// module doc for why this, not [`save`](Self::save), is what a real - /// mobile (or otherwise security-sensitive) binding should use. - pub fn save_to_keystore(&self, store: &dyn KeyStore) -> Result<(), KeyStoreError> { - store.save_seed(&self.private_bytes()) - } - - /// Reconstruct a keypair from a seed previously written by - /// [`save_to_keystore`](Self::save_to_keystore). Unlike - /// [`load`](Self::load), there is no separately-stored public key to - /// cross-check — a keystore-backed secret is either exactly the seed - /// this method wrote or [`KeyStoreError::InvalidSeedLength`], and the - /// public key a seed derives is always internally consistent by - /// construction (see [`from_seed_bytes`](Self::from_seed_bytes)). - pub fn load_from_keystore(store: &dyn KeyStore) -> Result { - Ok(Self::from_seed_bytes(store.load_seed()?)) - } -} - -#[cfg(unix)] -fn set_owner_only_permissions(path: &Path) -> io::Result<()> { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(0o600)) -} - -#[cfg(not(unix))] -fn set_owner_only_permissions(_path: &Path) -> io::Result<()> { - // No POSIX permission bits off Unix; the platform's own file ACLs - // apply. Real mobile builds should not be using this raw-file format - // at all — see `KeyPair::save`'s doc. - Ok(()) -} - -#[derive(Debug)] -pub enum LoadKeyError { - Io(io::Error), - BadKeyFile, - PubkeyMismatch, -} - -impl fmt::Display for LoadKeyError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - LoadKeyError::Io(e) => write!(f, "I/O error reading key file: {e}"), - LoadKeyError::BadKeyFile => write!(f, "key file has the wrong magic header or length"), - LoadKeyError::PubkeyMismatch => { - write!( - f, - "stored public key does not match the one derived from the stored private key" - ) - } - } - } -} - -impl std::error::Error for LoadKeyError {} - -impl From for LoadKeyError { - fn from(e: io::Error) -> Self { - LoadKeyError::Io(e) - } -} - -/// Verify `sig` over `msg` against `pubkey`. Matches -/// `macula_identity:verify/3`'s contract exactly: a structurally invalid -/// public key (not a valid Ed25519 point) is treated as "verification -/// failed" (`false`), not a separate error — it could not have produced -/// a valid signature either way. -pub fn verify(msg: &[u8], sig: &[u8; 64], pubkey: &[u8; 32]) -> bool { - let Ok(verifying_key) = VerifyingKey::from_bytes(pubkey) else { - return false; - }; - let signature = ed25519_dalek::Signature::from_bytes(sig); - verifying_key.verify(msg, &signature).is_ok() -} - -/// `SHA-256(pubkey)` — the proof-of-work output measured by the puzzle. -/// Cheap; not itself the expensive step (see the module doc). -pub fn puzzle_evidence(pubkey: &[u8; 32]) -> [u8; 32] { - Sha256::digest(pubkey).into() -} - -/// Whether `pubkey` satisfies the puzzle at `difficulty` (leading zero -/// bits of its [`puzzle_evidence`]). -pub fn puzzle_valid(pubkey: &[u8; 32], difficulty: u32) -> bool { - count_leading_zero_bits(&puzzle_evidence(pubkey)) >= difficulty -} - -fn count_leading_zero_bits(bytes: &[u8]) -> u32 { - let mut count = 0u32; - for &b in bytes { - if b == 0 { - count += 8; - } else { - count += b.leading_zeros(); - break; - } - } - count -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Captured directly from a real, random `crypto:generate_key(eddsa, - /// ed25519)` / `crypto:sign/4` / `crypto:hash(sha256, Pub)` in - /// `macula-io/macula`'s own `rebar3 shell` — see this module's doc - /// comment. Not a synthetic fixture. - const VECTOR_PUB: &str = "B966A9812649C3D5542FF54954FE090C43FDA6574FE48A0DD326626CFAD29A83"; - const VECTOR_PRIV: &str = "457F45FF5A09E172ED15CB20D6CB26B51AD15ED7308C12D478E8631F9CA03D4F"; - const VECTOR_MSG: &str = "6D6163756C612D76322D6672616D650068656C6C6F20776F726C64"; - const VECTOR_SIG: &str = "E8605CF0387CDFCDD88308A0E40A1DCB83402864C335A64D44431DC8ABC5E7E4FF16CA0C56231B32EEB312C4F89F20B6BA76280AFD622983E9D8BC5F4456AC0B"; - const VECTOR_PUZZLE_EVIDENCE: &str = - "09D48C91CB46513ED2580BDCEA87C40DA508D4E50EC3DF2F701AFC55D1C5C0B2"; - const VECTOR_LEADING_ZERO_BITS: u32 = 4; - - fn fixed_array(hex_str: &str) -> [u8; 32] { - hex::decode(hex_str) - .expect("valid hex fixture") - .try_into() - .expect("32-byte fixture") - } - - fn fixed_array64(hex_str: &str) -> [u8; 64] { - hex::decode(hex_str) - .expect("valid hex fixture") - .try_into() - .expect("64-byte fixture") - } - - #[test] - fn seed_derives_the_reference_pubkey() { - let kp = KeyPair::from_seed_bytes(fixed_array(VECTOR_PRIV)); - assert_eq!( - kp.public_bytes(), - fixed_array(VECTOR_PUB), - "ed25519-dalek's public-key derivation diverged from Erlang's crypto module" - ); - } - - #[test] - fn signature_matches_the_reference_byte_for_byte() { - let kp = KeyPair::from_seed_bytes(fixed_array(VECTOR_PRIV)); - let msg = hex::decode(VECTOR_MSG).unwrap(); - let sig = kp.sign(&msg); - assert_eq!( - sig, - fixed_array64(VECTOR_SIG), - "Ed25519 is deterministic (RFC 8032) — a mismatch here means \ - the two implementations disagree on the signing algorithm \ - itself, not just on random input" - ); - } - - #[test] - fn verify_accepts_the_reference_signature() { - let pubkey = fixed_array(VECTOR_PUB); - let msg = hex::decode(VECTOR_MSG).unwrap(); - let sig = fixed_array64(VECTOR_SIG); - assert!(verify(&msg, &sig, &pubkey)); - } - - #[test] - fn verify_rejects_a_tampered_message() { - let pubkey = fixed_array(VECTOR_PUB); - let sig = fixed_array64(VECTOR_SIG); - assert!(!verify(b"not the original message", &sig, &pubkey)); - } - - #[test] - fn verify_rejects_a_structurally_invalid_pubkey_without_panicking() { - // All-0xFF is not a valid Ed25519 point. - let bogus_pubkey = [0xFFu8; 32]; - let msg = hex::decode(VECTOR_MSG).unwrap(); - let sig = fixed_array64(VECTOR_SIG); - assert!(!verify(&msg, &sig, &bogus_pubkey)); - } - - #[test] - fn puzzle_evidence_matches_the_reference() { - let pubkey = fixed_array(VECTOR_PUB); - assert_eq!( - puzzle_evidence(&pubkey), - fixed_array(VECTOR_PUZZLE_EVIDENCE) - ); - } - - #[test] - fn puzzle_valid_matches_the_reference_leading_zero_count() { - let pubkey = fixed_array(VECTOR_PUB); - assert!(puzzle_valid(&pubkey, VECTOR_LEADING_ZERO_BITS)); - assert!(!puzzle_valid(&pubkey, VECTOR_LEADING_ZERO_BITS + 1)); - assert!(puzzle_valid(&pubkey, 0)); // 0 is always satisfied - } - - #[test] - fn generate_with_default_puzzle_produces_a_valid_identity() { - // A real grind, not a fixture — proves the loop terminates and - // its result actually satisfies the check it's grinding for. - // Sub-millisecond at the default difficulty per the Erlang - // reference's own comment; this test should be fast. - let kp = KeyPair::generate_with_default_puzzle(); - assert!(puzzle_valid(&kp.public_bytes(), DEFAULT_PUZZLE_DIFFICULTY)); - } - - #[test] - fn save_and_load_roundtrip() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("identity.key"); - - let original = KeyPair::from_seed_bytes(fixed_array(VECTOR_PRIV)); - original.save(&path).expect("save"); - - let loaded = KeyPair::load(&path).expect("load"); - assert_eq!(loaded.public_bytes(), original.public_bytes()); - assert_eq!(loaded.private_bytes(), original.private_bytes()); - } - - #[cfg(unix)] - #[test] - fn saved_key_file_is_owner_only() { - use std::os::unix::fs::PermissionsExt; - - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("identity.key"); - KeyPair::generate().save(&path).expect("save"); - - let mode = fs::metadata(&path).expect("metadata").permissions().mode(); - assert_eq!(mode & 0o777, 0o600); - } - - #[test] - fn load_rejects_a_corrupted_file() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("identity.key"); - fs::write(&path, b"not a key file").expect("write"); - - assert!(matches!( - KeyPair::load(&path), - Err(LoadKeyError::BadKeyFile) - )); - } - - #[test] - fn load_rejects_a_tampered_pubkey() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("identity.key"); - KeyPair::generate().save(&path).expect("save"); - - // Flip a byte inside the stored public key. - let mut blob = fs::read(&path).expect("read"); - let pub_offset = KEY_FILE_MAGIC.len(); - blob[pub_offset] ^= 0xFF; - fs::write(&path, &blob).expect("write tampered"); - - assert!(matches!( - KeyPair::load(&path), - Err(LoadKeyError::PubkeyMismatch) - )); - } -} diff --git a/src/keystore.rs b/src/keystore.rs index 89839f7..d2c90e9 100644 --- a/src/keystore.rs +++ b/src/keystore.rs @@ -1,9 +1,9 @@ -//! Overridable, per-platform secure storage for a persisted identity seed. +//! Overridable, per-platform secure storage for a node key. //! -//! [`KeyPair::save`](crate::identity::KeyPair::save)/[`load`](crate::identity::KeyPair::load) -//! write a raw file — explicitly documented there as "a testing/parity -//! convenience," not what a real mobile binding should use. This module is -//! the real answer: a small [`KeyStore`] trait plus [`KeyringStore`], a +//! [`NodeKey::save`](crate::node_key::NodeKey::save)/[`load`](crate::node_key::NodeKey::load) +//! write an owner-only key file, which suits a server or a desktop. A mobile +//! app keeps its key in the platform's secure store instead: this module is +//! a small [`KeyStore`] trait plus [`KeyringStore`], a //! default implementation backed by the `keyring` crate, which selects the //! actual native secure store per target automatically — //! Keychain (`Security.framework`) on macOS and iOS, Secret Service (D-Bus) @@ -16,8 +16,8 @@ //! [`KeyStore`] itself is deliberately not tied to `keyring` at all — a //! caller with a different secure-storage requirement (a hardware security //! module, a different vault) can implement the trait directly and hand it -//! to [`KeyPair::save_to_keystore`](crate::identity::KeyPair::save_to_keystore)/ -//! [`load_from_keystore`](crate::identity::KeyPair::load_from_keystore) — +//! to [`NodeKey::save_to_keystore`](crate::node_key::NodeKey::save_to_keystore)/ +//! [`load_from_keystore`](crate::node_key::NodeKey::load_from_keystore) — //! "overridable per target platform" is a property of the trait boundary, //! not something wired into this crate's own logic. //! @@ -64,24 +64,27 @@ //! real save/load/delete round trip in this environment. use keyring::Entry; +use macula_mldsa::Zeroizing; -/// Secure storage for a 32-byte identity seed. Implement this directly for -/// a backend other than [`KeyringStore`] (a hardware security module, a -/// different vault) — this is the override point "per target platform" -/// hangs off, not a platform enum this crate switches on internally. +/// Secure storage for one node key, as the bytes of its key file (the seed +/// form: the ML-DSA-87 seed, and in pq_hybrid the RSA-PSS key too, a few KiB). +/// Implement this directly for a backend other than [`KeyringStore`] (a +/// hardware security module, a different vault) — this is the override point +/// "per target platform" hangs off, not a platform enum this crate switches on +/// internally. pub trait KeyStore { - /// Persist `seed`, overwriting any value already stored under this + /// Persist `key`, overwriting any value already stored under this /// store's identity. - fn save_seed(&self, seed: &[u8; 32]) -> Result<(), KeyStoreError>; + fn save_key(&self, key: &[u8]) -> Result<(), KeyStoreError>; - /// Retrieve a previously-[`save_seed`](Self::save_seed)d seed. + /// Retrieve a previously-[`save_key`](Self::save_key)d key. /// [`KeyStoreError::NotFound`] if nothing has been stored yet. - fn load_seed(&self) -> Result<[u8; 32], KeyStoreError>; + fn load_key(&self) -> Result>, KeyStoreError>; - /// Remove a previously-stored seed, if any. Not required before a - /// [`save_seed`](Self::save_seed) (which overwrites), only for + /// Remove a previously-stored key, if any. Not required before a + /// [`save_key`](Self::save_key) (which overwrites), only for /// deliberately forgetting an identity. - fn delete_seed(&self) -> Result<(), KeyStoreError>; + fn delete_key(&self) -> Result<(), KeyStoreError>; } /// The default [`KeyStore`]: the platform-native secure store `keyring` @@ -104,24 +107,20 @@ impl KeyringStore { } impl KeyStore for KeyringStore { - fn save_seed(&self, seed: &[u8; 32]) -> Result<(), KeyStoreError> { - self.entry.set_secret(seed)?; + fn save_key(&self, key: &[u8]) -> Result<(), KeyStoreError> { + self.entry.set_secret(key)?; Ok(()) } - fn load_seed(&self) -> Result<[u8; 32], KeyStoreError> { - let secret = match self.entry.get_secret() { - Ok(secret) => secret, - Err(keyring::Error::NoEntry) => return Err(KeyStoreError::NotFound), - Err(e) => return Err(e.into()), - }; - let actual = secret.len(); - secret - .try_into() - .map_err(|_| KeyStoreError::InvalidSeedLength { actual }) + fn load_key(&self) -> Result>, KeyStoreError> { + match self.entry.get_secret() { + Ok(secret) => Ok(Zeroizing::new(secret)), + Err(keyring::Error::NoEntry) => Err(KeyStoreError::NotFound), + Err(e) => Err(e.into()), + } } - fn delete_seed(&self) -> Result<(), KeyStoreError> { + fn delete_key(&self) -> Result<(), KeyStoreError> { match self.entry.delete_credential() { Ok(()) => Ok(()), Err(keyring::Error::NoEntry) => Ok(()), @@ -164,24 +163,20 @@ impl LinuxKeyutilsStore { #[cfg(target_os = "linux")] impl KeyStore for LinuxKeyutilsStore { - fn save_seed(&self, seed: &[u8; 32]) -> Result<(), KeyStoreError> { - self.entry.set_secret(seed)?; + fn save_key(&self, key: &[u8]) -> Result<(), KeyStoreError> { + self.entry.set_secret(key)?; Ok(()) } - fn load_seed(&self) -> Result<[u8; 32], KeyStoreError> { - let secret = match self.entry.get_secret() { - Ok(secret) => secret, - Err(keyring_core::Error::NoEntry) => return Err(KeyStoreError::NotFound), - Err(e) => return Err(e.into()), - }; - let actual = secret.len(); - secret - .try_into() - .map_err(|_| KeyStoreError::InvalidSeedLength { actual }) + fn load_key(&self) -> Result>, KeyStoreError> { + match self.entry.get_secret() { + Ok(secret) => Ok(Zeroizing::new(secret)), + Err(keyring_core::Error::NoEntry) => Err(KeyStoreError::NotFound), + Err(e) => Err(e.into()), + } } - fn delete_seed(&self) -> Result<(), KeyStoreError> { + fn delete_key(&self) -> Result<(), KeyStoreError> { match self.entry.delete_credential() { Ok(()) => Ok(()), Err(keyring_core::Error::NoEntry) => Ok(()), @@ -192,11 +187,8 @@ impl KeyStore for LinuxKeyutilsStore { #[derive(Debug)] pub enum KeyStoreError { - /// No seed has been stored yet under this store's identity. + /// No key has been stored yet under this store's identity. NotFound, - /// A stored secret existed but wasn't 32 bytes — corrupted, or written - /// by something other than [`KeyStore::save_seed`]. - InvalidSeedLength { actual: usize }, /// The underlying platform secure store rejected the operation. Backend(keyring::Error), } @@ -204,10 +196,7 @@ pub enum KeyStoreError { impl std::fmt::Display for KeyStoreError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - KeyStoreError::NotFound => write!(f, "no seed stored under this identity"), - KeyStoreError::InvalidSeedLength { actual } => { - write!(f, "stored secret is {actual} bytes, expected 32") - } + KeyStoreError::NotFound => write!(f, "no key stored under this identity"), KeyStoreError::Backend(e) => write!(f, "platform secure store error: {e}"), } } @@ -268,19 +257,19 @@ mod tests { #[cfg(target_os = "linux")] #[test] - fn save_then_load_returns_the_same_seed() { + fn save_then_load_returns_the_same_key() { let _guard = KEYRING_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); let store = test_store(); - let seed = [0x42u8; 32]; + let key = vec![0x42u8; 2400]; let result = (|| -> Result<(), KeyStoreError> { - store.save_seed(&seed)?; - let loaded = store.load_seed()?; - assert_eq!(loaded, seed); + store.save_key(&key)?; + let loaded = store.load_key()?; + assert_eq!(*loaded, key); Ok(()) })(); - store.delete_seed().expect("cleanup delete should succeed"); + store.delete_key().expect("cleanup delete should succeed"); result.expect("save/load round trip should succeed"); } @@ -291,9 +280,9 @@ mod tests { let store = test_store(); // Guard against a leftover entry from a prior failed run on this // machine before asserting NotFound. - let _ = store.delete_seed(); + let _ = store.delete_key(); - assert!(matches!(store.load_seed(), Err(KeyStoreError::NotFound))); + assert!(matches!(store.load_key(), Err(KeyStoreError::NotFound))); } #[cfg(target_os = "linux")] @@ -301,12 +290,12 @@ mod tests { fn delete_is_idempotent() { let _guard = KEYRING_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); let store = test_store(); - store.save_seed(&[0x7Fu8; 32]).expect("save"); - store.delete_seed().expect("first delete"); + store.save_key(&[0x7Fu8; 32]).expect("save"); + store.delete_key().expect("first delete"); // A second delete of an already-absent entry must not error -- - // KeyStore::delete_seed's own doc promises this. + // KeyStore::delete_key's own doc promises this. store - .delete_seed() + .delete_key() .expect("second delete on an absent entry"); } } diff --git a/src/lib.rs b/src/lib.rs index c4d6f93..e939fdd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,31 +1,15 @@ -//! Rust port of macula's SDK (client/leaf) protocol — see -//! `plans/PLAN_WIRE_PROTOCOL.md` for the full wire-format spec this crate -//! is built against, traced directly to `macula-io/macula` source. +//! Rust SDK for the macula 12 mesh: node keys (ML-DSA-87, and the LAMPS +//! composite ML-DSA-87 + RSA-PSS-4096 in pq_hybrid), bindings and signed +//! objects, and a station dialed over QUIC with a post-quantum key exchange. //! -//! Mobile (iOS/Android via UniFFI) is the flagship consumer driving this -//! work, not the ceiling on it — nothing below the eventual FFI binding -//! layer is mobile-specific. +//! Mobile (iOS/Android via UniFFI, `macula-rust-ffi`) is the flagship +//! consumer, not the ceiling: nothing below the FFI layer is mobile-specific. pub mod binding; -pub mod bolt4; pub mod cbor; -pub mod cert; -pub mod cert_chain; -pub mod connection; -pub mod content; -mod control_channel; -pub mod dht; -pub mod direct_dial; -pub mod frame; -pub mod identity; pub mod keystore; -pub mod manifest; pub mod node_key; -mod open_sessions; pub mod petname; -pub mod pool; pub mod profile; pub mod signed_object; -pub mod stream; pub mod transport; -pub mod ucan; diff --git a/src/manifest.rs b/src/manifest.rs deleted file mode 100644 index 5a29131..0000000 --- a/src/manifest.rs +++ /dev/null @@ -1,663 +0,0 @@ -//! Fixed-size chunking, Merkle-root computation, and manifest -//! construction for content larger than one storage block. Ported from -//! macula's own `macula_manifest` (SDK) — see -//! `plans/PLAN_WIRE_PROTOCOL.md` §12.2. -//! -//! Mirrors the reference byte-for-byte: same MCID format, same default -//! chunk size (256 KiB), same Merkle fold (including the odd-leaf-count -//! rule — pair the last hash with itself), same canonical-CBOR MCID -//! derivation. Verified against real `macula_manifest:create/2` / -//! `chunk_mcid/3` / `verify/2` output (even *and* odd chunk counts, to -//! exercise both branches of the Merkle fold) — see this module's tests. -//! -//! **Two different wire representations of `name`, both verified -//! separately, not confused with each other:** `compute_mcid`'s -//! canonical hash input wraps `name` as CBOR *text* (a deliberate, -//! narrow special case in the reference, just for that hash -//! computation), while [`to_wire`] — the actual manifest map as sent in -//! a `_content.put_manifest` CALL payload — encodes `name` as a raw -//! *byte string*, matching its `binary()` type. Confirmed directly by -//! encoding a real manifest through the general deterministic-CBOR -//! codec and inspecting the bytes, not inferred from the type spec -//! alone — see the CALL/PUBLISH `procedure`/`topic` lesson in -//! `src/frame.rs` for why that inference alone wasn't trusted here. - -use crate::cbor::Value; - -/// 256 KiB — matches `macula_manifest:default_chunk_size/0`. -pub const DEFAULT_CHUNK_SIZE: usize = 262_144; - -const VERSION: u8 = 1; -const CODEC_RAW: u8 = 0x55; -const CODEC_MANIFEST: u8 = 0x56; - -/// `<>` — 34 bytes. -pub type Mcid = [u8; 34]; - -fn make_mcid(codec: u8, hash: [u8; 32]) -> Mcid { - let mut out = [0u8; 34]; - out[0] = VERSION; - out[1] = codec; - out[2..].copy_from_slice(&hash); - out -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Algorithm { - Blake3, - Sha256, -} - -impl Algorithm { - fn hash(self, data: &[u8]) -> [u8; 32] { - match self { - Algorithm::Blake3 => *blake3::hash(data).as_bytes(), - Algorithm::Sha256 => { - use sha2::{Digest, Sha256}; - Sha256::digest(data).into() - } - } - } - - pub fn name(self) -> &'static str { - match self { - Algorithm::Blake3 => "blake3", - Algorithm::Sha256 => "sha256", - } - } - - /// Matches `to_algorithm/1`'s own fallback: anything unrecognized - /// defaults to `blake3`, it doesn't error. - pub fn from_name(name: &str) -> Algorithm { - match name { - "sha256" => Algorithm::Sha256, - _ => Algorithm::Blake3, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ChunkInfo { - pub index: usize, - pub offset: usize, - pub size: usize, - pub hash: [u8; 32], -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Manifest { - pub mcid: Mcid, - pub version: u32, - pub name: String, - pub size: u64, - pub created: u64, - pub chunk_size: usize, - pub chunk_count: usize, - pub hash_algorithm: Algorithm, - pub root_hash: [u8; 32], - pub chunks: Vec, -} - -#[derive(Debug, Clone)] -pub struct CreateOptions { - pub name: String, - pub chunk_size: usize, - pub hash_algorithm: Algorithm, -} - -impl Default for CreateOptions { - fn default() -> Self { - Self { - name: "unnamed".to_string(), - chunk_size: DEFAULT_CHUNK_SIZE, - hash_algorithm: Algorithm::Blake3, - } - } -} - -/// Split `data` into fixed-size chunks and build its manifest. Returns -/// the manifest and the chunk bytes in order (index 0 first) — a caller -/// uploads each chunk (`_content.put_block`) then the manifest itself -/// (`_content.put_manifest`), per §12.2. -/// -/// `opts.chunk_size` must be non-zero (matches the reference: it never -/// guards against zero either, and a zero chunk size is a caller bug, -/// not a case worth silently tolerating). -pub fn create(data: &[u8], opts: &CreateOptions) -> (Manifest, Vec>) { - create_with_created(data, opts, current_unix_secs()) -} - -fn create_with_created( - data: &[u8], - opts: &CreateOptions, - created: u64, -) -> (Manifest, Vec>) { - let chunks = do_chunk(data, opts.chunk_size); - let chunk_infos = chunk_infos(&chunks, opts.hash_algorithm); - let root_hash = root_hash_for(&chunk_infos, opts.hash_algorithm); - let chunk_count = chunk_infos.len(); - let mcid = compute_mcid( - &opts.name, - data.len() as u64, - opts.chunk_size, - chunk_count, - opts.hash_algorithm, - &root_hash, - ); - let manifest = Manifest { - mcid, - version: 1, - name: opts.name.clone(), - size: data.len() as u64, - created, - chunk_size: opts.chunk_size, - chunk_count, - hash_algorithm: opts.hash_algorithm, - root_hash, - chunks: chunk_infos, - }; - (manifest, chunks) -} - -/// The MCID a chunk at `index` is stored/fetched under — the station -/// derives this same value independently when serving the chunk, so -/// both sides agree on its address without exchanging it. -pub fn chunk_mcid(manifest: &Manifest, index: usize) -> Option { - manifest - .chunks - .get(index) - .map(|c| make_mcid(CODEC_RAW, c.hash)) -} - -/// The MCID a whole blob is stored/fetched under when it's small enough -/// to be a single block (no manifest at all). Matches -/// `macula_content_transfer:put_single_block/3` exactly: **always** -/// BLAKE3, regardless of any algorithm preference — single-block content -/// has no algorithm choice, only chunked/manifest content does. -pub fn block_mcid(data: &[u8]) -> Mcid { - make_mcid(CODEC_RAW, Algorithm::Blake3.hash(data)) -} - -/// Whether `mcid` addresses a manifest (chunked content) rather than a -/// single raw block — determined from its own codec byte, no network -/// round trip needed. Matches `macula_content_transfer:is_chunked/2`'s -/// get-side check. -pub fn mcid_is_chunked(mcid: &Mcid) -> bool { - mcid[1] == CODEC_MANIFEST -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum VerifyError { - SizeMismatch, - RootHashMismatch, -} - -impl std::fmt::Display for VerifyError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - VerifyError::SizeMismatch => write!(f, "data size does not match the manifest"), - VerifyError::RootHashMismatch => { - write!(f, "re-chunked root hash does not match the manifest") - } - } - } -} - -impl std::error::Error for VerifyError {} - -/// Verify reassembled `data` against `manifest`: size, then a fresh -/// Merkle root over `data` re-chunked the same way. -pub fn verify(manifest: &Manifest, data: &[u8]) -> Result<(), VerifyError> { - if data.len() as u64 != manifest.size { - return Err(VerifyError::SizeMismatch); - } - let chunks = do_chunk(data, manifest.chunk_size); - let infos = chunk_infos(&chunks, manifest.hash_algorithm); - let actual_root = root_hash_for(&infos, manifest.hash_algorithm); - if actual_root == manifest.root_hash { - Ok(()) - } else { - Err(VerifyError::RootHashMismatch) - } -} - -fn do_chunk(data: &[u8], chunk_size: usize) -> Vec> { - // `[T]::chunks(n)` is exactly equivalent to the reference's - // recursive `do_chunk/3` for every case checked, including exact - // multiples of chunk_size (no trailing empty chunk) and empty input - // (zero chunks, not one empty chunk). - data.chunks(chunk_size).map(<[u8]>::to_vec).collect() -} - -fn chunk_infos(chunks: &[Vec], algorithm: Algorithm) -> Vec { - let mut offset = 0usize; - chunks - .iter() - .enumerate() - .map(|(index, chunk)| { - let info = ChunkInfo { - index, - offset, - size: chunk.len(), - hash: algorithm.hash(chunk), - }; - offset += chunk.len(); - info - }) - .collect() -} - -fn root_hash_for(infos: &[ChunkInfo], algorithm: Algorithm) -> [u8; 32] { - if infos.is_empty() { - return algorithm.hash(&[]); - } - let mut hashes: Vec<[u8; 32]> = infos.iter().map(|i| i.hash).collect(); - while hashes.len() > 1 { - hashes = combine(&hashes, algorithm); - } - hashes[0] -} - -/// One Merkle-fold pass: pairs from the front, `hash(L || R)`. An odd -/// leftover at the end is paired with itself, `hash(Last || Last)` — the -/// rule most likely to be implemented wrong; verified against an -/// odd-chunk-count reference vector specifically (see this module's -/// tests), not just even counts. -fn combine(hashes: &[[u8; 32]], algorithm: Algorithm) -> Vec<[u8; 32]> { - hashes - .chunks(2) - .map(|pair| { - let mut buf = Vec::with_capacity(64); - buf.extend_from_slice(&pair[0]); - buf.extend_from_slice(pair.get(1).unwrap_or(&pair[0])); - algorithm.hash(&buf) - }) - .collect() -} - -/// The canonical hash input for a manifest's own MCID — deliberately -/// excludes `created` (timestamp) and `chunks` (already rolled up into -/// `root_hash`). `name` and `hash_algorithm` are wrapped as CBOR text -/// here specifically, matching the reference's own special-cased -/// `compute_mcid/2` — see this module's doc comment for why that's -/// *not* the same encoding [`to_wire`] uses for `name`. -fn compute_mcid( - name: &str, - size: u64, - chunk_size: usize, - chunk_count: usize, - algorithm: Algorithm, - root_hash: &[u8; 32], -) -> Mcid { - let canonical = Value::Map(vec![ - (Value::text("name"), Value::text(name)), - (Value::text("size"), Value::Int(size as i128)), - (Value::text("chunk_size"), Value::Int(chunk_size as i128)), - (Value::text("chunk_count"), Value::Int(chunk_count as i128)), - (Value::text("hash_algorithm"), Value::text(algorithm.name())), - (Value::text("root_hash"), Value::Bytes(root_hash.to_vec())), - ]); - let bytes = crate::cbor::encode(&canonical).expect("manifest MCID fields are always encodable"); - let hash = algorithm.hash(&bytes); - make_mcid(CODEC_MANIFEST, hash) -} - -/// Encode `manifest` as it's actually sent in a `_content.put_manifest` -/// CALL payload — `name` as bytes (its real `binary()` type), NOT the -/// text-wrapped form `compute_mcid` uses internally. See this module's -/// doc comment. -pub fn to_wire(manifest: &Manifest) -> Value { - Value::Map(vec![ - (Value::text("mcid"), Value::Bytes(manifest.mcid.to_vec())), - (Value::text("version"), Value::Int(manifest.version as i128)), - ( - Value::text("name"), - Value::Bytes(manifest.name.as_bytes().to_vec()), - ), - (Value::text("size"), Value::Int(manifest.size as i128)), - (Value::text("created"), Value::Int(manifest.created as i128)), - ( - Value::text("chunk_size"), - Value::Int(manifest.chunk_size as i128), - ), - ( - Value::text("chunk_count"), - Value::Int(manifest.chunk_count as i128), - ), - ( - Value::text("hash_algorithm"), - Value::text(manifest.hash_algorithm.name()), - ), - ( - Value::text("root_hash"), - Value::Bytes(manifest.root_hash.to_vec()), - ), - ( - Value::text("chunks"), - Value::List(manifest.chunks.iter().map(chunk_info_to_wire).collect()), - ), - ]) -} - -fn chunk_info_to_wire(info: &ChunkInfo) -> Value { - Value::Map(vec![ - (Value::text("index"), Value::Int(info.index as i128)), - (Value::text("offset"), Value::Int(info.offset as i128)), - (Value::text("size"), Value::Int(info.size as i128)), - (Value::text("hash"), Value::Bytes(info.hash.to_vec())), - ]) -} - -#[derive(Debug, PartialEq, Eq)] -pub enum FromWireError { - MissingField(&'static str), - WrongFieldType(&'static str), - /// `chunk_size` is 0. Never produced by [`create`] (its own doc - /// comment already requires a non-zero `chunk_size`), so this only - /// ever rejects a malicious/malformed wire manifest — accepting it - /// would panic downstream: `[T]::chunks(0)` (called from - /// [`verify`]'s own `do_chunk`) panics unconditionally on a zero - /// chunk size, even for empty content. - ZeroChunkSize, - /// `chunk_count` doesn't match the actual number of entries in the - /// `chunks` list. Never produced by [`create`] (`chunk_count` is - /// always `chunk_infos.len()`), so this only ever rejects a - /// malicious/malformed wire manifest — accepting it would let a - /// caller iterate `0..chunk_count` past the real end of `chunks` - /// and panic on the resulting `None` from [`chunk_mcid`]. - InconsistentChunkCount, -} - -impl std::fmt::Display for FromWireError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - FromWireError::MissingField(name) => write!(f, "missing required field {name:?}"), - FromWireError::WrongFieldType(name) => write!(f, "field {name:?} has the wrong type"), - FromWireError::ZeroChunkSize => write!(f, "chunk_size is 0"), - FromWireError::InconsistentChunkCount => { - write!( - f, - "chunk_count does not match the number of entries in chunks" - ) - } - } - } -} - -impl std::error::Error for FromWireError {} - -/// Parse a manifest as received from a `_content.get_manifest` RESULT. -pub fn from_wire(value: &Value) -> Result { - let mcid: Mcid = get_bytes_exact(value, "mcid")?; - let version = get_uint(value, "version")? as u32; - let name = get_string_bytes(value, "name")?; - let size = get_uint(value, "size")?; - let created = get_uint(value, "created")?; - let chunk_size = get_uint(value, "chunk_size")? as usize; - let chunk_count = get_uint(value, "chunk_count")? as usize; - let hash_algorithm = Algorithm::from_name(&get_text(value, "hash_algorithm")?); - let root_hash: [u8; 32] = get_bytes_exact(value, "root_hash")?; - let chunks = match value.get("chunks") { - Some(Value::List(items)) => items - .iter() - .map(chunk_info_from_wire) - .collect::, _>>()?, - Some(_) => return Err(FromWireError::WrongFieldType("chunks")), - None => return Err(FromWireError::MissingField("chunks")), - }; - if chunk_size == 0 { - return Err(FromWireError::ZeroChunkSize); - } - if chunks.len() != chunk_count { - return Err(FromWireError::InconsistentChunkCount); - } - Ok(Manifest { - mcid, - version, - name, - size, - created, - chunk_size, - chunk_count, - hash_algorithm, - root_hash, - chunks, - }) -} - -fn chunk_info_from_wire(value: &Value) -> Result { - Ok(ChunkInfo { - index: get_uint(value, "index")? as usize, - offset: get_uint(value, "offset")? as usize, - size: get_uint(value, "size")? as usize, - hash: get_bytes_exact(value, "hash")?, - }) -} - -fn get_uint(value: &Value, field: &'static str) -> Result { - match value.get(field) { - Some(Value::Int(n)) if *n >= 0 => Ok(*n as u64), - Some(_) => Err(FromWireError::WrongFieldType(field)), - None => Err(FromWireError::MissingField(field)), - } -} - -fn get_text(value: &Value, field: &'static str) -> Result { - match value.get(field) { - Some(Value::Text(t)) => Ok(t.clone()), - Some(_) => Err(FromWireError::WrongFieldType(field)), - None => Err(FromWireError::MissingField(field)), - } -} - -fn get_string_bytes(value: &Value, field: &'static str) -> Result { - match value.get(field) { - Some(Value::Bytes(b)) => { - String::from_utf8(b.clone()).map_err(|_| FromWireError::WrongFieldType(field)) - } - Some(_) => Err(FromWireError::WrongFieldType(field)), - None => Err(FromWireError::MissingField(field)), - } -} - -fn get_bytes_exact( - value: &Value, - field: &'static str, -) -> Result<[u8; N], FromWireError> { - match value.get(field) { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| FromWireError::WrongFieldType(field)), - Some(_) => Err(FromWireError::WrongFieldType(field)), - None => Err(FromWireError::MissingField(field)), - } -} - -fn current_unix_secs() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock is after the Unix epoch") - .as_secs() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn hex_bytes(s: &str) -> Vec { - ::hex::decode(s).expect("valid hex fixture") - } - - /// Even chunk count (4) — captured from a real `macula_manifest:create/2` - /// via `rebar3 shell` against `macula-io/macula`. - #[test] - fn even_chunk_count_matches_the_reference() { - let data = b"AAAABBBBCCCCD"; // 13 bytes - let opts = CreateOptions { - name: "test-file".to_string(), - chunk_size: 4, - hash_algorithm: Algorithm::Blake3, - }; - let (manifest, chunks) = create_with_created(data, &opts, 0); - - assert_eq!(chunks.len(), 4); - assert_eq!(chunks[0], b"AAAA"); - assert_eq!(chunks[3], b"D"); - - assert_eq!( - hex::encode_upper(manifest.root_hash), - "784F87CDC9C180A21C878FC26703F9E4782F2FD2E6235048299811675E36EAC4" - ); - assert_eq!( - hex::encode_upper(manifest.mcid), - "01564CC855EF538530393E36DBD4CCD216558B60F87498889890247EEB9B52B8FED7" - ); - - // Per-chunk hashes and offsets, spot-checked against the same run. - assert_eq!(manifest.chunks[0].offset, 0); - assert_eq!( - hex::encode_upper(manifest.chunks[0].hash), - "26C7BB3DAAAA0439EB3E5C5270E7C4DB05218D8892A0258FBD4911CEF5006D23" - ); - assert_eq!(manifest.chunks[3].offset, 12); - assert_eq!(manifest.chunks[3].size, 1); - - assert_eq!( - chunk_mcid(&manifest, 0).map(hex::encode_upper), - Some( - "015526C7BB3DAAAA0439EB3E5C5270E7C4DB05218D8892A0258FBD4911CEF5006D23".to_string() - ) - ); - - assert_eq!(verify(&manifest, data), Ok(())); - } - - /// Odd chunk count (3) — exercises the Merkle fold's "pair the last - /// hash with itself" branch, which the even-count test above never - /// touches. - #[test] - fn odd_chunk_count_matches_the_reference() { - let data = b"AAAABBBBCCCC"; // 12 bytes, chunk_size 4 -> exactly 3 chunks - let opts = CreateOptions { - name: "odd-test".to_string(), - chunk_size: 4, - hash_algorithm: Algorithm::Blake3, - }; - let (manifest, chunks) = create_with_created(data, &opts, 0); - - assert_eq!(chunks.len(), 3); - assert_eq!( - hex::encode_upper(manifest.root_hash), - "50FE839CCDE80B13D7531A9C34FD856DBCBBB87D8FBD241DE6AFF2C86909CD54" - ); - assert_eq!( - hex::encode_upper(manifest.mcid), - "0156589728C90DB0138CA87E4E500A61812C64D30C3BE325184A761F20CA04BC86FB" - ); - - assert_eq!(verify(&manifest, data), Ok(())); - assert_eq!( - verify(&manifest, b"AAAABBBBWRONG"), - Err(VerifyError::SizeMismatch) - ); - } - - #[test] - fn verify_rejects_tampered_content_of_the_same_size() { - let data = b"AAAABBBBCCCC"; - let opts = CreateOptions { - chunk_size: 4, - ..Default::default() - }; - let (manifest, _) = create_with_created(data, &opts, 0); - assert_eq!( - verify(&manifest, b"AAAABBBBCCCX"), - Err(VerifyError::RootHashMismatch) - ); - } - - /// The full manifest map as it's actually sent in a - /// `_content.put_manifest` CALL payload — captured by encoding a - /// real manifest through `macula_cbor_nif:pack_deterministic/1` - /// directly (the same general codec CALL payloads go through), not - /// through `compute_mcid`'s special canonical path. This is what - /// proves `name` really is bytes on the wire, not text. - #[test] - fn to_wire_matches_the_reference_byte_for_byte() { - let data = b"AAAABBBBCCCC"; - let opts = CreateOptions { - name: "odd-test".to_string(), - chunk_size: 4, - hash_algorithm: Algorithm::Blake3, - }; - let (manifest, _) = create_with_created(data, &opts, 1_787_892_082); // 0x6A911172 - - let wire = to_wire(&manifest); - let encoded = crate::cbor::encode(&wire).expect("encodable manifest"); - assert_eq!( - encoded, - hex_bytes( - "AA646D63696458220156589728C90DB0138CA87E4E500A61812C64D30C3BE325184A761F20CA04BC86FB646E616D65486F64642D746573746473697A650C666368756E6B7383A46468617368582026C7BB3DAAAA0439EB3E5C5270E7C4DB05218D8892A0258FBD4911CEF5006D236473697A650465696E64657800666F666673657400A464686173685820255EC90F561EDA98B1E5E3EFA56B7B477086E273CD07CC4F780A646D052726446473697A650465696E64657801666F666673657404A464686173685820A83CE6EC6760EB7F66D3D7BBC84D1AAC3BEF0948074F8ED21423D825AE8821726473697A650465696E64657802666F66667365740867637265617465641A6A9111726776657273696F6E0169726F6F745F68617368582050FE839CCDE80B13D7531A9C34FD856DBCBBB87D8FBD241DE6AFF2C86909CD546A6368756E6B5F73697A65046B6368756E6B5F636F756E74036E686173685F616C676F726974686D66626C616B6533" - ) - ); - - // Round-trip through from_wire. - let decoded = crate::cbor::decode(&encoded).expect("valid CBOR"); - let parsed = from_wire(&decoded).expect("well-formed manifest"); - assert_eq!(parsed, manifest); - } - - #[test] - fn from_wire_rejects_a_missing_field() { - let value = Value::Map(vec![(Value::text("mcid"), Value::Bytes(vec![0; 34]))]); - assert_eq!( - from_wire(&value), - Err(FromWireError::MissingField("version")) - ); - } - - /// A malicious/malformed manifest claiming `chunk_size: 0` must be - /// rejected at parse time, not accepted and left to panic later: - /// `verify`'s own `do_chunk` calls `[T]::chunks(manifest.chunk_size)`, - /// which panics unconditionally when its argument is 0. - #[test] - fn from_wire_rejects_zero_chunk_size() { - let (manifest, _) = create_with_created(b"AAAABBBBCCCC", &CreateOptions::default(), 0); - let tampered = to_wire(&manifest).with_field("chunk_size", Value::Int(0)); - assert_eq!(from_wire(&tampered), Err(FromWireError::ZeroChunkSize)); - } - - /// A malicious/malformed manifest whose `chunk_count` doesn't match - /// the actual number of entries in `chunks` must be rejected at - /// parse time: a caller iterating `0..chunk_count` (see - /// `content::get`) would otherwise index past the real end of - /// `chunks` and panic on the resulting `None`. - #[test] - fn from_wire_rejects_inconsistent_chunk_count() { - let opts = CreateOptions { - chunk_size: 4, - ..CreateOptions::default() - }; - let (manifest, _) = create_with_created(b"AAAABBBBCCCC", &opts, 0); - let tampered = to_wire(&manifest).with_field("chunk_count", Value::Int(1000)); - assert_eq!( - from_wire(&tampered), - Err(FromWireError::InconsistentChunkCount) - ); - } - - #[test] - fn algorithm_from_name_defaults_to_blake3() { - assert_eq!(Algorithm::from_name("blake3"), Algorithm::Blake3); - assert_eq!(Algorithm::from_name("sha256"), Algorithm::Sha256); - assert_eq!(Algorithm::from_name("something-unknown"), Algorithm::Blake3); - } - - #[test] - fn empty_data_produces_zero_chunks() { - let (manifest, chunks) = create_with_created(b"", &CreateOptions::default(), 0); - assert_eq!(chunks.len(), 0); - assert_eq!(manifest.chunk_count, 0); - } -} diff --git a/src/node_key/key_file.rs b/src/node_key/key_file.rs index 4c10000..65c7fd6 100644 --- a/src/node_key/key_file.rs +++ b/src/node_key/key_file.rs @@ -14,6 +14,7 @@ use aws_lc_rs::signature::KeyPair as _; use macula_mldsa::{PrivateKey, Zeroizing, ML_DSA_87}; use super::{der, verify, NodeKey, Purpose, RsaHalf}; +use crate::keystore::{KeyStore, KeyStoreError}; use crate::profile::Profile; /// Opens every key file this crate writes. macula's own key files hold the @@ -57,6 +58,8 @@ pub enum KeyFileError { PublicKeyMismatch, /// The key does not sign and verify as a whole. RoundTripFailed, + /// The key store could not save or load the key. + KeyStore(KeyStoreError), } impl fmt::Display for KeyFileError { @@ -83,6 +86,7 @@ impl fmt::Display for KeyFileError { f.write_str("the stored public key is not the one its private key derives") } KeyFileError::RoundTripFailed => f.write_str("the key does not sign and verify"), + KeyFileError::KeyStore(e) => write!(f, "key store: {e}"), } } } @@ -134,6 +138,28 @@ impl NodeKey { Ok(key) } + /// Keeps the key in `store`, as the bytes of its key file: the platform + /// secure store a mobile app keeps its key in (see `crate::keystore`). + pub fn save_to_keystore(&self, store: &dyn KeyStore) -> Result<(), KeyFileError> { + store + .save_key(&self.file_bytes()?) + .map_err(KeyFileError::KeyStore) + } + + /// The key kept in `store` for `purpose` in `profile`, checked as a key + /// file's is, but for the file's owner and permissions, which the store + /// keeps. + pub fn load_from_keystore( + store: &dyn KeyStore, + purpose: Purpose, + profile: Profile, + ) -> Result { + let contents = store.load_key().map_err(KeyFileError::KeyStore)?; + let key = parse(&contents, purpose, profile)?; + round_trip(&key)?; + Ok(key) + } + /// The key laid out as a key file. fn file_bytes(&self) -> Result, KeyFileError> { let mut out = MAGIC.to_vec(); diff --git a/src/open_sessions.rs b/src/open_sessions.rs deleted file mode 100644 index 2039eb4..0000000 --- a/src/open_sessions.rs +++ /dev/null @@ -1,243 +0,0 @@ -//! The sessions this process has open, at most one per identity and -//! station, so direct dial can reuse one instead of dialing again. -//! -//! A station keeps one connection per identity: when a newer connection -//! under an identity completes its handshake, the station closes the older -//! one (`macula_station_listener.erl`, `replaced_by_newer_handshake`). Code -//! that needs a station under an identity this process already holds a -//! session to must therefore reuse that session, or it closes its own -//! session by dialing. Direct dial looks here before it dials. -//! -//! A session registers once its HELLO is accepted and unregisters when it -//! closes. The newest registration for a pair wins, matching the station, -//! and unregistering removes an entry only if it still holds that same -//! session, so closing an older session never drops a newer one. Entries -//! are weak: a session dropped without closing, or whose connection has -//! ended, is not found. -//! -//! A session direct dial dialed for its own requests carries [`Leases`]: -//! every request using it holds one, so it closes when the last request is -//! done, and it is not reused once it is closing. A session its owner opened -//! carries none, and direct dial never closes it. - -use std::collections::HashMap; -use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, Weak}; - -/// Whether an open session's connection can still carry a request. -pub(crate) trait Live { - fn is_live(&self) -> bool; -} - -/// A session that may carry [`Leases`]: one direct dial dialed does, one its -/// owner opened doesn't. -pub(crate) trait Leased { - fn leases(&self) -> Option<&Leases>; -} - -/// The requests using a session direct dial dialed, counted so the session -/// closes when the last one is done and is not reused once it is closing. -/// The request that dialed the session holds the first lease. -pub(crate) struct Leases { - count: Mutex, -} - -impl Leases { - pub(crate) fn new() -> Self { - Self { - count: Mutex::new(1), - } - } - - /// Takes one more lease, unless the last one was already released. - pub(crate) fn try_lease(&self) -> bool { - let mut count = self.count.lock().unwrap_or_else(PoisonError::into_inner); - if *count == 0 { - return false; - } - *count += 1; - true - } - - /// Gives one lease back, and says whether it was the last, so the session - /// is to be closed. A release after the last one does nothing. - pub(crate) fn release(&self) -> bool { - let mut count = self.count.lock().unwrap_or_else(PoisonError::into_inner); - if *count == 0 { - return false; - } - *count -= 1; - *count == 0 - } -} - -/// An identity's node id and a station's node id. -type Pair = ([u8; 32], [u8; 32]); - -pub(crate) struct OpenSessions { - open: Mutex>>, -} - -impl Default for OpenSessions { - fn default() -> Self { - Self { - open: Mutex::new(HashMap::new()), - } - } -} - -impl OpenSessions { - pub(crate) fn register(&self, identity: [u8; 32], station: [u8; 32], session: &Arc) { - self.lock() - .insert((identity, station), Arc::downgrade(session)); - } - - #[cfg(test)] - pub(crate) fn unregister(&self, identity: [u8; 32], station: [u8; 32], session: &Arc) { - self.unregister_pointer(identity, station, Arc::as_ptr(session)); - } - - /// [`unregister`](Self::unregister), for a session only a weak reference - /// is left to, such as one whose last handle is being dropped. - pub(crate) fn unregister_weak(&self, identity: [u8; 32], station: [u8; 32], session: &Weak) { - self.unregister_pointer(identity, station, session.as_ptr()); - } - - fn unregister_pointer(&self, identity: [u8; 32], station: [u8; 32], session: *const C) { - let mut open = self.lock(); - if open - .get(&(identity, station)) - .is_some_and(|held| std::ptr::eq(held.as_ptr(), session)) - { - open.remove(&(identity, station)); - } - } - - pub(crate) fn find(&self, identity: [u8; 32], station: [u8; 32]) -> Option> { - let mut open = self.lock(); - let found = open - .get(&(identity, station)) - .and_then(Weak::upgrade) - .filter(|session| session.is_live()); - if found.is_none() { - open.remove(&(identity, station)); - } - found - } - - // A panic elsewhere while the lock was held leaves the map itself intact. - fn lock(&self) -> MutexGuard<'_, HashMap>> { - self.open.lock().unwrap_or_else(PoisonError::into_inner) - } -} - -/// This process's open sessions, which every -/// [`Session`](crate::connection::Session) registers with. -pub(crate) fn live() -> &'static OpenSessions { - static LIVE: OnceLock> = OnceLock::new(); - LIVE.get_or_init(OpenSessions::default) -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::{AtomicBool, Ordering}; - - use super::*; - - struct FakeSession { - live: AtomicBool, - } - - impl Live for FakeSession { - fn is_live(&self) -> bool { - self.live.load(Ordering::Relaxed) - } - } - - fn open_session() -> Arc { - Arc::new(FakeSession { - live: AtomicBool::new(true), - }) - } - - fn node_id() -> [u8; 32] { - rand::random() - } - - fn holds(found: Option>, session: &Arc) -> bool { - found.is_some_and(|found| Arc::ptr_eq(&found, session)) - } - - #[test] - fn a_session_is_found_by_its_identity_and_station() { - let (identity, station) = (node_id(), node_id()); - let open = OpenSessions::default(); - let session = open_session(); - - open.register(identity, station, &session); - - assert!(holds(open.find(identity, station), &session)); - assert!(open.find(node_id(), station).is_none()); - assert!(open.find(identity, node_id()).is_none()); - } - - #[test] - fn the_newest_session_per_identity_and_station_wins() { - let (identity, station) = (node_id(), node_id()); - let open = OpenSessions::default(); - let (older, newer) = (open_session(), open_session()); - - open.register(identity, station, &older); - open.register(identity, station, &newer); - - assert!(holds(open.find(identity, station), &newer)); - } - - #[test] - fn closing_an_older_session_leaves_the_newer_one_registered() { - let (identity, station) = (node_id(), node_id()); - let open = OpenSessions::default(); - let (older, newer) = (open_session(), open_session()); - open.register(identity, station, &older); - open.register(identity, station, &newer); - - open.unregister(identity, station, &older); - - assert!(holds(open.find(identity, station), &newer)); - } - - #[test] - fn a_closed_session_is_no_longer_found() { - let (identity, station) = (node_id(), node_id()); - let open = OpenSessions::default(); - let session = open_session(); - open.register(identity, station, &session); - - open.unregister(identity, station, &session); - - assert!(open.find(identity, station).is_none()); - } - - #[test] - fn a_dropped_session_is_no_longer_found() { - let (identity, station) = (node_id(), node_id()); - let open = OpenSessions::default(); - let session = open_session(); - open.register(identity, station, &session); - - drop(session); - - assert!(open.find(identity, station).is_none()); - } - - #[test] - fn a_session_whose_connection_ended_is_no_longer_found() { - let (identity, station) = (node_id(), node_id()); - let open = OpenSessions::default(); - let session = open_session(); - open.register(identity, station, &session); - - session.live.store(false, Ordering::Relaxed); - - assert!(open.find(identity, station).is_none()); - } -} diff --git a/src/pool.rs b/src/pool.rs deleted file mode 100644 index 5e0ba02..0000000 --- a/src/pool.rs +++ /dev/null @@ -1,1204 +0,0 @@ -//! A multi-station connection pool: dials several [`Session`]s concurrently -//! (bootstrap seeds, optionally grown by discovering more via -//! `hecate_stations.list_stations`) and gives [`Pool::call`]/ -//! [`Pool::publish`] a choice of which connected one to use, instead of a -//! caller managing a single [`Session`] by hand. -//! -//! **Call/Publish only — no pooled Subscribe.** A caller that needs -//! Subscribe uses [`Session::subscribe`]/[`Session::run_subscriber`] on a -//! session directly, unpooled. Each link's [`Session`] runs its own reader, -//! so calls and publishes on one link run at the same time. -//! -//! Ported from the same station-discovery/link-rotation design already -//! shipped in `macula-go` (`pool/discovery.go`, v0.7.0) and `macula-dotnet` -//! (`StationDiscovery.cs`, v0.4.0) — see those crates' own doc comments for -//! the fuller cross-language history. This is a from-scratch build, not a -//! port of an existing pool: this crate had no `Seed`/multi-link concept at -//! all before this module. -//! -//! **Per-link trust, not one fixed mode for the whole pool** — this is the -//! one deliberate design difference from the go/dotnet ports, made possible -//! by building from scratch rather than extending an existing single-Trust -//! pool: a discovered station whose directory row has NO `hostname` (only a -//! bare-IP `host_advertised`) but DOES carry a `node_id` dials under -//! `Trust::Pinned(node_id)` instead of being skipped outright the way -//! go/dotnet's ports skip every hostname-less row under `Trust::WebPki` -//! (which can never validate a bare IP with no IP SANs). The underlying -//! MECHANISM mirrors a shipped, live-verified precedent in a completely -//! different codebase: `macula-apps/macula-cam2me`'s Android client -//! (`reachability/StationDiscovery.kt`), confirmed against the real fleet -//! by 34 (`macula`'s own reference-implementation session) independently -//! reaching the identical conclusion this module reaches, including a -//! live TLS-layer `verify=none` warning from `macula_quic` when dialing a -//! hostname'd station by IP+Pinned instead of its usual WebPki path. -//! -//! **This module's PRIORITY ORDER deliberately differs from cam2me's own, -//! in the safer direction** — cam2me picks Pinned(node_id) whenever a row -//! carries a node_id at all (true of essentially every row), falling back -//! to hostname/WebPki only when `host_advertised` itself is missing; that -//! trades away TLS-layer MITM resistance for the common case, not just the -//! no-DNS one. Here, `dial_target_from_station_row` prefers `hostname` -//! unconditionally — `Trust::Pinned` is chosen only when a row has NO -//! usable hostname at all, so a normal Let's-Encrypt-backed station still -//! dials WebPki exactly as it always has; only a genuine no-DNS station -//! (`stations-linode-toronto` — see `tests/live_station.rs`'s own -//! `pinned_trust_full_handshake_succeeds_against_toronto`) ever falls to -//! Pinned. Bootstrap seeds are entirely unaffected either way — they -//! always dial under the pool's own configured `Trust`. - -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::sync::Arc; -use std::time::Duration; - -use tokio::sync::{watch, Mutex, RwLock}; -use tokio::task::JoinSet; - -use crate::connection::{self, CallError, Session}; -use crate::dht; -use crate::frame::{CallResponse, CallSpec, PublishSpec}; -use crate::identity::KeyPair; -use crate::transport::Trust; - -/// A dial target: host+port only, no identity attached (every link in a -/// pool shares the pool's one identity). Mirrors macula-go's -/// `connection.Seed` / macula-dotnet's `Seed` record — this crate had no -/// equivalent type before this pool. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct Seed { - pub host: String, - pub port: u16, -} - -impl Seed { - pub fn new(host: impl Into, port: u16) -> Self { - Self { - host: host.into(), - port, - } - } -} - -/// How [`Pool::call`]/[`Pool::publish`] order the pool's currently-connected -/// links before applying their own existing first-match/`replication_factor` -/// logic — changes ORDER only, never how many links get used. Matches -/// macula_client.erl's own `link_selection` option (`first_success`/ -/// `random`) and macula-go/macula-dotnet's identically-named enum, so -/// config ported from any of those doesn't need re-learning. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum LinkSelection { - /// (The default.) Derives the actual policy from - /// [`StationDiscoveryOptions::enabled`]: [`FirstSuccess`](Self::FirstSuccess) - /// if discovery is off (this pool's original, only behavior, unchanged), - /// [`Random`](Self::Random) if it's on. - #[default] - Auto, - /// Tries links in Seed-list order (bootstrap seeds in the order the - /// caller gave them, then discovered links in discovery order) — this - /// pool's baseline behavior, since `links` is a plain append-only - /// `Vec` (see [`Pool`]'s own doc on why that, not a `HashMap`, is the - /// backing store — insertion order is the ordering, nothing extra to - /// track). - FirstSuccess, - /// Uniformly shuffles the connected-links list before the same - /// first-match (Call) or take-first-N (Publish) logic runs. Composes - /// safely with a small `replication_factor`: shuffling ahead of a - /// 1-element slice is a no-op. - Random, -} - -fn resolve_link_selection( - configured: LinkSelection, - station_discovery_enabled: bool, -) -> LinkSelection { - match configured { - LinkSelection::Auto if station_discovery_enabled => LinkSelection::Random, - LinkSelection::Auto => LinkSelection::FirstSuccess, - other => other, - } -} - -fn select_links( - mut connected: Vec>, - resolved: LinkSelection, -) -> Vec> { - if resolved != LinkSelection::Random || connected.len() <= 1 { - return connected; - } - use rand::seq::SliceRandom; - connected.shuffle(&mut rand::rng()); - connected -} - -/// Configures opt-in discovery of additional stations via -/// `hecate_stations.list_stations`, layered on top of the caller-supplied -/// bootstrap [`Seed`]s. Default (`enabled == false`) is a complete no-op. -/// -/// Bootstrap seeds keep their exact meaning: dialed first, permanent -/// fallback if discovery never succeeds, retried forever on failure, never -/// replaced. Discovery only ADDS links — a station missing from a later -/// refresh does NOT tear down an existing link. A DISCOVERY-added link that -/// fails to dial `DISCOVERY_LINK_MAX_RESPAWN_ATTEMPTS` (5) times in a row -/// gives up and frees its slot (so a future refresh can try a different -/// station instead) — unlike a bootstrap seed, which never gives up. Go's -/// and dotnet's ports of this same feature have no such give-up mechanism -/// (a permanently-unreachable discovered station wastes a slot forever, -/// redialing at the flat respawn delay indefinitely); building this pool -/// from scratch, with 34's parallel design work on the Erlang reference -/// specifically adding this exception, was reason enough to include it here -/// too rather than carry the narrower behavior forward by default. -#[derive(Debug, Clone)] -pub struct StationDiscoveryOptions { - pub enabled: bool, - /// Interval between discovery attempts once at least one bootstrap - /// link is up. Default 30 minutes. - pub refresh_interval: Duration, - /// Bounds discovery's OWN adds only, not the pool's total link count — - /// see macula-dotnet's identical `StationDiscoveryOptions.MaxLinks` doc - /// for the exact accounting rules this mirrors. - pub max_links: usize, -} - -impl Default for StationDiscoveryOptions { - fn default() -> Self { - Self { - enabled: false, - refresh_interval: Duration::from_secs(30 * 60), - max_links: 5, - } - } -} - -/// Tunables for [`Pool`]. Defaults match every other port of this feature. -#[derive(Debug, Clone)] -pub struct PoolOptions { - pub link_selection: LinkSelection, - pub station_discovery: StationDiscoveryOptions, - /// Flat delay before redialing a link after it dies (or fails to dial - /// in the first place). Default 1s — flat, not exponential, matching - /// the reference and every other port in this SDK family except ts. - pub respawn_delay: Duration, - /// Per-CALL timeout, passed through to [`Session::call`]. Default - /// matches [`connection::DEFAULT_CALL_TIMEOUT`]. - pub call_timeout: Duration, - /// How many currently-connected links a single publish fans out to. - /// Partial success counts as success. Default 1, matching macula-ts - /// and macula-dotnet's own `ReplicationFactor` default. - pub replication_factor: usize, -} - -impl Default for PoolOptions { - fn default() -> Self { - Self { - link_selection: LinkSelection::default(), - station_discovery: StationDiscoveryOptions::default(), - respawn_delay: DEFAULT_RESPAWN_DELAY, - call_timeout: connection::DEFAULT_CALL_TIMEOUT, - replication_factor: 1, - } - } -} - -pub const DEFAULT_RESPAWN_DELAY: Duration = Duration::from_secs(1); - -/// Discovery-added links only (never bootstrap seeds) give up redialing -/// after this many consecutive failures — see -/// [`StationDiscoveryOptions`]'s own doc for why. -const DISCOVERY_LINK_MAX_RESPAWN_ATTEMPTS: u32 = 5; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LinkOrigin { - Bootstrap, - Discovered, -} - -/// A bootstrap link never gives up — matches every other port of this -/// pool shape, and macula_client.erl's own reference behavior for -/// caller-supplied seeds. Only a discovery-added link, after -/// [`DISCOVERY_LINK_MAX_RESPAWN_ATTEMPTS`] straight failures, gives up — -/// see [`StationDiscoveryOptions`]'s own doc for why this exception -/// exists at all. -fn should_give_up(origin: LinkOrigin, consecutive_failures: u32) -> bool { - origin == LinkOrigin::Discovered && consecutive_failures >= DISCOVERY_LINK_MAX_RESPAWN_ATTEMPTS -} - -struct LinkState { - session: Option, - peer_node_id: Option<[u8; 32]>, -} - -/// One configured link's current state. Never removed from [`Pool`]'s own -/// `links` list once added (see that field's own doc) — `connected` simply -/// goes false when the link is down or still dialing. -pub struct PooledLink { - pub seed: Seed, - origin: LinkOrigin, - /// This link's OWN trust — usually the pool's configured `Trust`, but - /// see [`Pool`]'s module-level doc for the one case (a discovered, - /// hostname-less, node_id-bearing row) where it differs per link. - trust: Trust, - /// Held only to read or swap the link's session, never across a round - /// trip: a caller clones the session handle out first. Only the link's - /// own lifecycle ([`run_link_lifecycle`]) installs a session and clears - /// it when it ends; [`Pool::close`] takes it to close it. - state: Mutex, - connected: AtomicBool, - /// Discovery-added links only: consecutive failed dial attempts, reset - /// on a successful connect. Bootstrap links never read this — they - /// retry forever regardless. - consecutive_failures: AtomicU32, - gave_up: AtomicBool, -} - -impl PooledLink { - fn new(seed: Seed, origin: LinkOrigin, trust: Trust) -> Arc { - Arc::new(Self { - seed, - origin, - trust, - state: Mutex::new(LinkState { - session: None, - peer_node_id: None, - }), - connected: AtomicBool::new(false), - consecutive_failures: AtomicU32::new(0), - gave_up: AtomicBool::new(false), - }) - } - - pub fn is_connected(&self) -> bool { - self.connected.load(Ordering::Acquire) - } - - /// A discovery-added link that has permanently given up redialing — see - /// [`StationDiscoveryOptions`]'s own doc. Always `false` for a - /// bootstrap link, which never gives up. - fn has_given_up(&self) -> bool { - self.gave_up.load(Ordering::Acquire) - } - - async fn peer_node_id(&self) -> Option<[u8; 32]> { - self.state.lock().await.peer_node_id - } -} - -/// Snapshot of one link, for health/introspection. -#[derive(Debug, Clone)] -pub struct LinkInfo { - pub seed: Seed, - pub connected: bool, - pub node_id: Option<[u8; 32]>, -} - -/// Aggregate health snapshot. Lock-free best-effort read. -#[derive(Debug, Clone, Copy)] -pub struct PoolStatus { - pub healthy_links: usize, - pub total_links: usize, -} - -impl PoolStatus { - /// At least one link has completed its CONNECT/HELLO handshake. - pub fn is_healthy(&self) -> bool { - self.healthy_links > 0 - } -} - -#[derive(Debug)] -pub enum PoolCallError { - /// No link in the pool has completed its CONNECT/HELLO handshake. - NoHealthyStation, - /// The failure that stopped the call, on the last link it was tried on. - /// The pool moves on to the next link only while a call fails before its - /// CALL was sent ([`CallError::not_sent`]), so no CALL runs twice. - Call(CallError), -} - -impl std::fmt::Display for PoolCallError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PoolCallError::NoHealthyStation => { - write!(f, "pool: no link has completed its CONNECT/HELLO handshake") - } - PoolCallError::Call(e) => write!(f, "pool: call failed: {e}"), - } - } -} - -impl std::error::Error for PoolCallError {} - -#[derive(Debug)] -pub enum PoolPublishError { - NoHealthyStation, - /// Every link the publish was routed to failed — carries the count. - AllFailed(usize), -} - -impl std::fmt::Display for PoolPublishError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PoolPublishError::NoHealthyStation => { - write!(f, "pool: no link has completed its CONNECT/HELLO handshake") - } - PoolPublishError::AllFailed(n) => { - write!(f, "pool: publish failed on all {n} targeted link(s)") - } - } - } -} - -impl std::error::Error for PoolPublishError {} - -/// A multi-station connection pool — see this module's own doc for the -/// full design. -/// -/// `links` is a plain append-only `Vec>` behind an async -/// `RwLock`, deliberately NOT a `HashMap`/`HashSet` — this is the one -/// change made in direct response to the SAME class of bug found (twice, -/// independently) porting this feature to macula-go (`map[string]*link` -/// randomizing iteration order) and macula-dotnet (migrating to -/// `ConcurrentDictionary` for concurrent-add safety silently broke -/// `FirstSuccess`'s reliance on insertion order). A `Vec`, appended to -/// under the same lock that's ALSO taken to read it, has no separate -/// enumeration-order concept to accidentally break — insertion order IS -/// the order, by construction, with nothing to track alongside it the way -/// go's fix (tracking iteration order separately) or dotnet's fix (an -/// explicit `Ordinal` field) both had to. -pub struct Pool { - identity: Arc, - trust: Trust, - options: PoolOptions, - links: RwLock>>, - stop_tx: watch::Sender, - /// Every background task this pool has spawned (a link's respawn - /// lifecycle, the discovery loop). `Pool::close` hard-aborts and - /// awaits every one of these BEFORE draining/closing `links` — found - /// necessary by adversarial review, 2026-09-05: without this, a task - /// mid-dial (`connection::connect` has no internal cooperation point - /// with the pool's own stop signal) or mid-discovery-push could - /// complete AFTER `close` returns and resurrect a link, or a live, - /// connected `Session`, that nothing will ever close again. Sequencing - /// `shutdown()` strictly before the drain guarantees any task that - /// manages to finish its own critical section either finishes before - /// `close` starts draining (so the drain sees and closes it) or gets - /// aborted before it can (so nothing is left to see). - tasks: Mutex>, -} - -impl Pool { - /// Spawn a pool with one link per seed. Returns as soon as every link's - /// dial has STARTED, not once any is connected — handshakes complete - /// asynchronously, matching macula_client:connect/2 and every other - /// port of this pool shape. - pub fn connect( - seeds: Vec, - trust: Trust, - identity: KeyPair, - options: PoolOptions, - ) -> Arc { - assert!(!seeds.is_empty(), "at least one seed is required"); - let (stop_tx, _stop_rx) = watch::channel(false); - let identity = Arc::new(identity); - let pool = Arc::new(Pool { - identity: identity.clone(), - trust, - options, - links: RwLock::new(Vec::new()), - stop_tx, - tasks: Mutex::new(JoinSet::new()), - }); - - let bootstrap_links: Vec> = seeds - .into_iter() - .map(|seed| PooledLink::new(seed, LinkOrigin::Bootstrap, trust)) - .collect(); - - { - // Bootstrap links are known synchronously at construction, so - // this can be a blocking write via try_write rather than - // spawning a task just to populate the initial Vec -- no - // other task holds the lock yet. - let mut links = pool - .links - .try_write() - .expect("no other task can hold this lock before Pool::connect returns"); - links.extend(bootstrap_links.iter().cloned()); - } - - { - // Same reasoning as the links lock above: nothing else can - // hold this lock yet either. - let mut tasks = pool - .tasks - .try_lock() - .expect("no other task can hold this lock before Pool::connect returns"); - for link in bootstrap_links { - tasks.spawn(run_link_lifecycle(pool.clone(), link)); - } - if pool.options.station_discovery.enabled { - tasks.spawn(discover_stations_loop(pool.clone())); - } - } - - pool - } - - /// Send a signed CALL on the currently-connected links, in the order - /// [`PoolOptions::link_selection`] gives them. The pool moves on to the - /// next link only when a call failed before its CALL was sent - /// ([`CallError::not_sent`]), so no CALL runs twice: a call that timed out - /// after its write started is returned as it is, and so is a BOLT#4 ERROR - /// reply, exactly like a bare [`Session::call`]. A link whose session - /// ends is dialed again by the pool. Pool calls publish no RPC telemetry - /// facts, as macula's pool doesn't. - pub async fn call( - &self, - procedure: &str, - realm: [u8; 32], - payload: crate::cbor::Value, - deadline_ms: i128, - ) -> Result { - let calls = self.select_connected_links().await.into_iter().map(|link| { - let spec = CallSpec::new( - rand::random(), - procedure, - realm, - payload.clone(), - deadline_ms, - self.identity.node_id(), - ); - async move { - // A handle, cloned out, so no lock is held across the round trip. - let session = link.state.lock().await.session.clone(); - let Some(session) = session else { - return Err(link_not_connected(&link)); - }; - session - .link_call(&spec, &self.identity, self.options.call_timeout) - .await - } - }); - call_until_sent(calls).await - } - - /// Send a signed PUBLISH, fanning out to up to - /// [`PoolOptions::replication_factor`] currently-connected links - /// (ordered by [`PoolOptions::link_selection`]). Partial success counts - /// as success, matching macula-ts/macula-dotnet's own publish-fanout - /// contract. - pub async fn publish(&self, spec: &PublishSpec) -> Result<(), PoolPublishError> { - let candidates = self.select_connected_links().await; - if candidates.is_empty() { - return Err(PoolPublishError::NoHealthyStation); - } - let targets: Vec<_> = candidates - .into_iter() - .take(self.options.replication_factor.max(1)) - .collect(); - let attempted = targets.len(); - let mut successes = 0usize; - for link in targets { - let Some(session) = link.state.lock().await.session.clone() else { - continue; - }; - if session.publish(spec, &self.identity).await.is_ok() { - successes += 1; - } - } - if successes > 0 { - Ok(()) - } else { - Err(PoolPublishError::AllFailed(attempted)) - } - } - - /// Aggregate health snapshot. - pub async fn status(&self) -> PoolStatus { - let links = self.links.read().await; - let healthy_links = links.iter().filter(|l| l.is_connected()).count(); - PoolStatus { - healthy_links, - total_links: links.len(), - } - } - - /// Per-link snapshot, in seed-list/discovery order — see [`Pool`]'s own - /// doc on why a plain `Vec` already guarantees this without any extra - /// bookkeeping. - pub async fn links(&self) -> Vec { - let links = self.links.read().await; - let mut out = Vec::with_capacity(links.len()); - for link in links.iter() { - out.push(LinkInfo { - seed: link.seed.clone(), - connected: link.is_connected(), - node_id: if link.is_connected() { - link.peer_node_id().await - } else { - None - }, - }); - } - out - } - - /// Sends GOODBYE on every currently-connected link and stops all - /// background dial/discovery tasks. Waits for every background task - /// (respawn lifecycles, the discovery loop) to actually be gone - /// BEFORE draining/closing `links` — see [`Pool`]'s own field doc on - /// `tasks` for why this ordering, specifically, is load-bearing. - /// Does not wait for the GOODBYE writes themselves to finish being - /// scheduled beyond [`Session::close`]'s own bounded drain. - pub async fn close(&self, reason: &str, detail: Option<&str>) { - let _ = self.stop_tx.send(true); - self.tasks.lock().await.shutdown().await; - let mut links = self.links.write().await; - for link in links.drain(..) { - let mut state = link.state.lock().await; - if let Some(session) = state.session.take() { - session.close(reason, detail, &self.identity).await; - } - link.connected.store(false, Ordering::Release); - } - } - - async fn select_connected_links(&self) -> Vec> { - let links = self.links.read().await; - let connected: Vec> = - links.iter().filter(|l| l.is_connected()).cloned().collect(); - drop(links); - let resolved = resolve_link_selection( - self.options.link_selection, - self.options.station_discovery.enabled, - ); - select_links(connected, resolved) - } -} - -/// Runs `calls` in turn and moves on to the next only when a call failed -/// before its CALL was sent ([`CallError::not_sent`]), so no CALL runs -/// twice. Returns the first reply, a BOLT#4 ERROR reply included, else the -/// failure that stopped it: a call that was or may have been sent, or the -/// last one. -async fn call_until_sent( - calls: impl IntoIterator, -) -> Result -where - F: std::future::Future>, -{ - let mut calls = calls.into_iter().peekable(); - while let Some(call) = calls.next() { - match call.await { - // Never sent on this link, so the next one can't run it twice. - Err(e) if e.not_sent() && calls.peek().is_some() => {} - done => return done.map_err(PoolCallError::Call), - } - } - Err(PoolCallError::NoHealthyStation) -} - -/// The failure of a call on a link that lost its session after it was -/// selected, which never sent anything. -fn link_not_connected(link: &PooledLink) -> CallError { - CallError::SessionEnded { - reason: crate::connection::SessionEndReason::StreamFailed(format!( - "link to {}:{} is not connected", - link.seed.host, link.seed.port - )), - write_started: false, - } -} - -/// Marks `link` disconnected if it still holds `ended`: [`Pool::close`] may -/// have taken the session already. -async fn mark_disconnected(link: &PooledLink, ended: &Session) { - let mut state = link.state.lock().await; - if state - .session - .as_ref() - .is_some_and(|session| session.is_same_session(ended)) - { - state.session = None; - link.connected.store(false, Ordering::Release); - } -} - -/// Resolves once the pool is stopping, or gone. -async fn pool_stopping(stop_rx: &mut watch::Receiver) { - let _ = stop_rx.wait_for(|stopped| *stopped).await; -} - -/// Runs `link` for as long as the pool does: dials it, keeps it connected -/// until its session ends, and dials it again after -/// [`PoolOptions::respawn_delay`]. A failed dial is retried after the same -/// delay, and a discovery-added link whose dial fails -/// [`DISCOVERY_LINK_MAX_RESPAWN_ATTEMPTS`] times in a row gives up. Each link -/// has exactly one of these, spawned when the link is added, so a link is -/// never dialed twice at once. -async fn run_link_lifecycle(pool: Arc, link: Arc) { - let mut stop_rx = pool.stop_tx.subscribe(); - loop { - if *stop_rx.borrow() { - return; - } - match connection::connect(&link.seed.host, link.seed.port, link.trust, &pool.identity).await - { - Ok(session) => { - let node_id = session.station.node_id; - let mut state = link.state.lock().await; - state.session = Some(session.clone()); - state.peer_node_id = Some(node_id); - drop(state); - link.connected.store(true, Ordering::Release); - link.consecutive_failures.store(0, Ordering::Release); - // Pool::close closes the session itself. - tokio::select! { - _ = session.ended() => {} - () = pool_stopping(&mut stop_rx) => return, - } - mark_disconnected(&link, &session).await; - } - Err(_) => { - let failures = link.consecutive_failures.fetch_add(1, Ordering::AcqRel) + 1; - if should_give_up(link.origin, failures) { - link.gave_up.store(true, Ordering::Release); - return; - } - } - } - tokio::select! { - () = tokio::time::sleep(pool.options.respawn_delay) => {} - () = pool_stopping(&mut stop_rx) => return, - } - } -} - -// --------------------------------------------------------------------- -// Station discovery — resolves hecate_stations.list_stations' realm via -// the DHT, calls it through the pool's own `call` (never a raw Session), -// and additively adds links for whatever it finds. -// --------------------------------------------------------------------- - -const LIST_STATIONS_PROCEDURE: &str = "hecate_stations.list_stations"; -/// `hecate_stations.list_stations` itself is called under its OWN resolved -/// realm (whatever [`resolve_list_stations_realm`] found), NOT the DHT's -/// own all-zero realm — that's `dht::find_records_by_type`'s realm -/// (`dht.rs`'s own private `DHT_REALM` constant), a separate, unrelated -/// realm this module never needs to name directly since it only ever -/// reaches the DHT through `dht::find_records_by_type` itself. -const DISCOVERY_CALL_DEADLINE: Duration = Duration::from_secs(5); - -async fn discover_stations_loop(pool: Arc) { - // Trust::Pinned can never validate a SECOND station's identity -- - // running discovery at all under it is unconditionally pointless, not - // just risky (it would otherwise dial-storm forever against stations - // it can never actually trust). Matches the identical guard in - // macula-go's and macula-dotnet's own ports of this feature. - if matches!(pool.trust, Trust::Pinned(_)) { - return; - } - - let mut stop_rx = pool.stop_tx.subscribe(); - if !wait_for_any_healthy_link(&pool, &mut stop_rx).await { - return; - } - - loop { - if *stop_rx.borrow() { - return; - } - discover_once(&pool).await; - tokio::select! { - _ = tokio::time::sleep(pool.options.station_discovery.refresh_interval) => {} - _ = stop_rx.changed() => { - if *stop_rx.borrow() { - return; - } - } - } - } -} - -async fn wait_for_any_healthy_link(pool: &Arc, stop_rx: &mut watch::Receiver) -> bool { - loop { - if *stop_rx.borrow() { - return false; - } - if pool.status().await.healthy_links > 0 { - return true; - } - tokio::select! { - _ = tokio::time::sleep(Duration::from_millis(200)) => {} - _ = stop_rx.changed() => { - if *stop_rx.borrow() { - return false; - } - } - } - } -} - -async fn discover_once(pool: &Arc) { - let Some(realm) = resolve_list_stations_realm(pool).await else { - return; - }; - - let deadline = now_ms() + DISCOVERY_CALL_DEADLINE.as_millis() as i128; - let Ok(CallResponse::Result { payload, .. }) = pool - .call( - LIST_STATIONS_PROCEDURE, - realm, - crate::cbor::Value::Map(vec![]), - deadline, - ) - .await - else { - return; - }; - let crate::cbor::Value::Map(fields) = &payload else { - return; - }; - let Some(crate::cbor::Value::List(stations)) = fields - .iter() - .find(|(k, _)| matches!(k, crate::cbor::Value::Text(t) if t == "stations")) - .map(|(_, v)| v.clone()) - else { - return; - }; - - add_discovered_links(pool, &stations).await; -} - -/// Resolves `hecate_stations.list_stations`' own realm by scanning every -/// `procedure_advertisement` DHT record visible from the pool's current -/// bootstrap connection, matching a `procedure_uri` of the shape -/// `hex(realm) + "/hecate_stations.list_stations"` — mirrors -/// `dht::discovery_uri`'s own format and macula-go/macula-dotnet's -/// identical resolution step. Returns `None` on any failure (no -/// advertisement found yet, none verify, no healthy link to ask with) — -/// discovery just tries again at the next refresh tick, same as a bare DHT -/// lookup miss anywhere else in this crate. -async fn resolve_list_stations_realm(pool: &Arc) -> Option<[u8; 32]> { - let links = pool.links.read().await; - let link = links.iter().find(|l| l.is_connected())?.clone(); - drop(links); - - let session = link.state.lock().await.session.clone()?; - let records = - dht::find_records_by_type(&session, &pool.identity, dht::TYPE_PROCEDURE_ADVERTISEMENT) - .await - .ok()?; - - for record in records { - if dht::verify(&record).is_err() { - continue; - } - let Ok(advertisement) = dht::read_procedure_advertisement(&record) else { - continue; - }; - if let Some(realm) = try_match_list_stations_realm(&advertisement.procedure_uri) { - return Some(realm); - } - } - None -} - -fn try_match_list_stations_realm(procedure_uri: &str) -> Option<[u8; 32]> { - let suffix = format!("/{LIST_STATIONS_PROCEDURE}"); - let hex_realm = procedure_uri.strip_suffix(&suffix)?; - if hex_realm.len() != 64 { - return None; - } - let bytes = hex_decode(hex_realm)?; - bytes.try_into().ok() -} - -fn hex_decode(s: &str) -> Option> { - if s.len() % 2 != 0 { - return None; - } - (0..s.len()) - .step_by(2) - .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()) - .collect() -} - -/// How many of `links` currently count against -/// [`StationDiscoveryOptions::max_links`] — MaxLinks bounds discovery's OWN -/// adds only, not the pool's total link count (see that field's own doc), -/// so a bootstrap seed must NEVER count against this budget, however many -/// were configured, or a pool started with >= max_links bootstrap seeds (a -/// realistic deployment) would have discovery silently do nothing, forever, -/// from its very first refresh — caught by adversarial review, 2026-09-05, -/// after an earlier version of this count included every link regardless -/// of origin. A given-up discovery link ALSO stays in the pool's `links` -/// forever (no removal path — see [`Pool`]'s own doc on why the backing -/// store is a plain `Vec`) but must NOT keep occupying its own slot either, -/// or the whole point of giving up (freeing room for a different -/// candidate) is defeated. -fn count_occupied_discovery_slots(links: &[Arc]) -> usize { - links - .iter() - .filter(|l| l.origin == LinkOrigin::Discovered && !l.has_given_up()) - .count() -} - -async fn add_discovered_links(pool: &Arc, stations: &[crate::cbor::Value]) { - let is_web_pki = matches!(pool.trust, Trust::WebPki); - for station in stations { - let links = pool.links.read().await; - let occupied_slots = count_occupied_discovery_slots(&links); - drop(links); - if occupied_slots >= pool.options.station_discovery.max_links { - break; - } - let Some((host, port, node_id)) = dial_target_from_station_row(station) else { - continue; - }; - let is_bare_ip = host.parse::().is_ok(); - - // Prefer a real hostname under the pool's own configured trust; - // fall back to Trust::Pinned(node_id) for a bare-IP-only row when - // a node_id is available -- see this module's own doc for why, - // and cam2me's StationDiscovery.kt for the shipped precedent. - let link_trust = if is_bare_ip && is_web_pki { - match node_id { - Some(id) => Trust::Pinned(id), - None => continue, // no way to validate this station at all - } - } else { - pool.trust - }; - - if let Some(id) = node_id { - if has_link_for_node_id(pool, id).await { - continue; - } - } - - let seed = Seed::new(host, port); - spawn_seed_link_if_absent(pool, seed, link_trust).await; - } -} - -/// Extracts a dialable `(host, port, node_id)` from one -/// `hecate_stations.list_stations` response row. Prefers `hostname` over -/// `host_advertised[0]` when both are present — every station on the real -/// fleet advertises `host_advertised` as a bare IP literal, never a DNS -/// name (confirmed live, same finding independently hit by macula-go's and -/// macula-dotnet's own ports of this feature), so `hostname` is the only -/// field a WebPki dial can ever succeed against. `host_advertised` is -/// still read out (and returned) even when a `hostname` is present, since -/// callers that end up needing `Trust::Pinned` (see -/// [`add_discovered_links`]) dial by IP regardless of whether a hostname -/// also exists. -pub(crate) fn dial_target_from_station_row( - row: &crate::cbor::Value, -) -> Option<(String, u16, Option<[u8; 32]>)> { - let crate::cbor::Value::Map(fields) = row else { - return None; - }; - let get = |name: &str| { - fields - .iter() - .find(|(k, _)| matches!(k, crate::cbor::Value::Text(t) if t == name)) - .map(|(_, v)| v) - }; - - let port = match get("quic_port") { - Some(crate::cbor::Value::Int(n)) if (1..=65535).contains(n) => *n as u16, - _ => return None, - }; - - let hostname = match get("hostname") { - Some(crate::cbor::Value::Text(t)) if !t.is_empty() => Some(t.clone()), - Some(crate::cbor::Value::Bytes(b)) if !b.is_empty() => String::from_utf8(b.clone()).ok(), - _ => None, - }; - let host_advertised = match get("host_advertised") { - Some(crate::cbor::Value::List(items)) => items.iter().find_map(|item| match item { - crate::cbor::Value::Bytes(b) => String::from_utf8(b.clone()).ok(), - crate::cbor::Value::Text(t) => Some(t.clone()), - _ => None, - }), - _ => None, - }; - - let host = hostname.or(host_advertised)?; - let node_id = match get("node_id") { - Some(crate::cbor::Value::Bytes(b)) => b.as_slice().try_into().ok(), - _ => None, - }; - Some((host, port, node_id)) -} - -async fn has_link_for_node_id(pool: &Arc, node_id: [u8; 32]) -> bool { - let links = pool.links.read().await; - for link in links.iter() { - if link.is_connected() { - if let Some(known) = link.peer_node_id().await { - if known == node_id { - return true; - } - } - } - } - false -} - -async fn spawn_seed_link_if_absent(pool: &Arc, seed: Seed, trust: Trust) { - let mut links = pool.links.write().await; - if links.iter().any(|l| l.seed == seed) { - return; - } - let link = PooledLink::new(seed, LinkOrigin::Discovered, trust); - links.push(link.clone()); - drop(links); - pool.tasks - .lock() - .await - .spawn(run_link_lifecycle(pool.clone(), link)); -} - -fn now_ms() -> i128 { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock before 1970") - .as_millis() as i128 -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::control_channel::fake_station::{call, connect, reply_text, WAIT}; - - fn row(fields: Vec<(&str, crate::cbor::Value)>) -> crate::cbor::Value { - crate::cbor::Value::Map( - fields - .into_iter() - .map(|(k, v)| (crate::cbor::Value::Text(k.to_string()), v)) - .collect(), - ) - } - - #[test] - fn resolve_link_selection_auto_pairs_with_discovery() { - assert_eq!( - resolve_link_selection(LinkSelection::Auto, false), - LinkSelection::FirstSuccess - ); - assert_eq!( - resolve_link_selection(LinkSelection::Auto, true), - LinkSelection::Random - ); - } - - #[test] - fn resolve_link_selection_explicit_survives_either_way() { - assert_eq!( - resolve_link_selection(LinkSelection::FirstSuccess, true), - LinkSelection::FirstSuccess - ); - assert_eq!( - resolve_link_selection(LinkSelection::Random, false), - LinkSelection::Random - ); - } - - #[test] - fn dial_target_prefers_hostname_over_bare_ip() { - let r = row(vec![ - ( - "hostname", - crate::cbor::Value::Text("station-de-frankfurt.macula.io".into()), - ), - ( - "host_advertised", - crate::cbor::Value::List(vec![crate::cbor::Value::Bytes( - b"2a01:7e01::f03c:94ff:fe22:719e".to_vec(), - )]), - ), - ("quic_port", crate::cbor::Value::Int(4433)), - ]); - let (host, port, _) = dial_target_from_station_row(&r).expect("should parse"); - assert_eq!(host, "station-de-frankfurt.macula.io"); - assert_eq!(port, 4433); - } - - #[test] - fn dial_target_falls_back_to_host_advertised_when_hostname_absent() { - let r = row(vec![ - ( - "host_advertised", - crate::cbor::Value::List(vec![crate::cbor::Value::Bytes( - b"2600:3c0b::2000:1fff:fe35:416b".to_vec(), - )]), - ), - ("quic_port", crate::cbor::Value::Int(4433)), - ]); - let (host, _, _) = dial_target_from_station_row(&r).expect("should parse"); - assert_eq!(host, "2600:3c0b::2000:1fff:fe35:416b"); - } - - #[test] - fn dial_target_rejects_missing_port() { - let r = row(vec![("hostname", crate::cbor::Value::Text("x".into()))]); - assert!(dial_target_from_station_row(&r).is_none()); - } - - #[test] - fn dial_target_extracts_node_id() { - let node_id = [0xABu8; 32]; - let r = row(vec![ - ("hostname", crate::cbor::Value::Text("x".into())), - ("quic_port", crate::cbor::Value::Int(4433)), - ("node_id", crate::cbor::Value::Bytes(node_id.to_vec())), - ]); - let (_, _, id) = dial_target_from_station_row(&r).expect("should parse"); - assert_eq!(id, Some(node_id)); - } - - #[test] - fn should_give_up_never_applies_to_a_bootstrap_link() { - assert!(!should_give_up( - LinkOrigin::Bootstrap, - DISCOVERY_LINK_MAX_RESPAWN_ATTEMPTS - )); - assert!(!should_give_up(LinkOrigin::Bootstrap, 1_000_000)); - } - - #[test] - fn should_give_up_applies_to_a_discovered_link_at_the_threshold() { - assert!(!should_give_up( - LinkOrigin::Discovered, - DISCOVERY_LINK_MAX_RESPAWN_ATTEMPTS - 1 - )); - assert!(should_give_up( - LinkOrigin::Discovered, - DISCOVERY_LINK_MAX_RESPAWN_ATTEMPTS - )); - } - - fn synthetic_link(origin: LinkOrigin, gave_up: bool) -> Arc { - let link = PooledLink::new(Seed::new("x.example", 4433), origin, Trust::WebPki); - link.gave_up.store(gave_up, Ordering::Relaxed); - link - } - - /// Regression test for the exact bug adversarial review caught: an - /// earlier version of this count included bootstrap links, which can - /// never give up, so a pool started with `bootstrap.len() >= - /// max_links` would have discovery silently do nothing forever. - #[test] - fn discovery_slot_count_excludes_bootstrap_links_entirely() { - let links = vec![ - synthetic_link(LinkOrigin::Bootstrap, false), - synthetic_link(LinkOrigin::Bootstrap, false), - synthetic_link(LinkOrigin::Bootstrap, false), - ]; - assert_eq!(count_occupied_discovery_slots(&links), 0); - } - - #[test] - fn discovery_slot_count_excludes_a_given_up_discovered_link() { - let links = vec![ - synthetic_link(LinkOrigin::Discovered, false), - synthetic_link(LinkOrigin::Discovered, true), // gave up -- slot freed - ]; - assert_eq!(count_occupied_discovery_slots(&links), 1); - } - - #[test] - fn discovery_slot_count_mixed_origins() { - let links = vec![ - synthetic_link(LinkOrigin::Bootstrap, false), - synthetic_link(LinkOrigin::Bootstrap, false), - synthetic_link(LinkOrigin::Discovered, false), - synthetic_link(LinkOrigin::Discovered, true), - ]; - assert_eq!(count_occupied_discovery_slots(&links), 1); - } - - #[tokio::test] - async fn call_falls_through_to_next_connected_link() { - let (gone, gone_station, gone_ended) = connect(); - drop(gone_station); - tokio::time::timeout(WAIT, gone_ended) - .await - .unwrap() - .unwrap(); - let (live, mut station, _ended) = connect(); - let identity = KeyPair::generate(); - let (first, second) = (call("app/echo"), call("app/echo")); - - let (called, ()) = tokio::join!( - call_until_sent([ - gone.call(&first, &identity, WAIT, None), - live.call(&second, &identity, WAIT, None), - ]), - async { - let sent = station.next("call").await; - station.reply(&sent, "echoed").await; - } - ); - - assert_eq!(reply_text(called.unwrap()), "echoed"); - } - - #[tokio::test] - async fn a_pool_call_that_timed_out_after_its_write_started_is_not_tried_on_another_link() { - let (stalled, stalled_station, _stalled_ended) = connect(); - let (other, mut other_station, _other_ended) = connect(); - stalled_station.stall_session_writes(); - let identity = KeyPair::generate(); - let (first, second) = (call("app/echo"), call("app/echo")); - - let called = call_until_sent([ - stalled.call(&first, &identity, Duration::from_millis(100), None), - other.call(&second, &identity, WAIT, None), - ]) - .await; - - assert!( - matches!( - called, - Err(PoolCallError::Call(CallError::Timeout { - write_started: true - })) - ), - "{called:?}" - ); - assert!( - other_station - .nothing_sent_within(Duration::from_millis(300)) - .await - ); - stalled_station.resume_session_writes(); - } - - #[test] - fn try_match_list_stations_realm_matches_expected_format() { - let hex_realm = "0".repeat(64); - let uri = format!("{hex_realm}/hecate_stations.list_stations"); - assert_eq!(try_match_list_stations_realm(&uri), Some([0u8; 32])); - } - - #[test] - fn try_match_list_stations_realm_rejects_a_different_procedure() { - let hex_realm = "0".repeat(64); - let uri = format!("{hex_realm}/some.other_procedure"); - assert_eq!(try_match_list_stations_realm(&uri), None); - } - - #[test] - fn select_links_first_success_returns_input_unshuffled() { - // Empty/zero-length input is the simplest observable proof this - // is a passthrough -- Vec equality on non-empty synthetic - // PooledLinks would need constructing real Arcs with - // no real Session, which is exactly the "no fake-dialer seam" - // situation macula-dotnet's own tests document; a live pool test - // covers the real end-to-end ordering instead. - let empty: Vec> = Vec::new(); - assert_eq!(select_links(empty, LinkSelection::FirstSuccess).len(), 0); - } -} diff --git a/src/stream.rs b/src/stream.rs deleted file mode 100644 index d77cf70..0000000 --- a/src/stream.rs +++ /dev/null @@ -1,676 +0,0 @@ -//! General-purpose streaming RPC, caller/consumer role (§13.1 of -//! `plans/PLAN_WIRE_PROTOCOL.md`), ported from `macula_stream_sink.erl`. -//! Like content transfer (`src/content.rs`), this is not a separate wire -//! mechanism: it runs the frame types built in `src/frame.rs` §13 over a -//! dedicated QUIC stream, opened via -//! [`Session::open_dedicated_stream`](crate::connection::Session::open_dedicated_stream) -//! rather than the control stream. -//! -//! **Both roles are built.** Caller/consumer (§13.1) opens a stream and -//! is the natural fit for pulling/pushing against a procedure that -//! already exists somewhere. Provider (§13.2) advertises a procedure -//! (§6.9, [`Session::advertise`](crate::connection::Session::advertise)) -//! and answers inbound STREAM_OPENs the station routes back — -//! [`Session::accept_dedicated_stream`](crate::connection::Session::accept_dedicated_stream) -//! accepts the fresh dedicated stream the station opens toward us, -//! [`StreamHandle::accept`] reads and parses the STREAM_OPEN that's -//! always its first frame. Both roles end up holding the same -//! [`StreamHandle`] afterward — a stream's wire vocabulary -//! (STREAM_DATA/END/ERROR/REPLY) is symmetric regardless of which side -//! opened it, so `send_data`/`recv`/`close_send`/`abort` all mean the -//! same thing either way. [`StreamHandle::send_reply`] is the one -//! provider-only addition: sending the terminal STREAM_REPLY a -//! `client_stream`/`bidi` caller's own -//! [`await_reply`](StreamHandle::await_reply) is waiting on. -//! -//! Caller/consumer usage, matching the reference's own pattern: -//! 1. [`StreamHandle::open`] sends STREAM_OPEN and returns a handle once -//! the frame is on the wire — there's no open-time acknowledgement to -//! wait for; the provider starts reacting to it directly. -//! 2. Drive a receive loop with [`StreamHandle::recv`] until -//! [`StreamItem::Eof`] or an error. -//! 3. For `client_stream`/`bidi` modes wanting a result: -//! [`StreamHandle::send_data`] each chunk in order, -//! [`StreamHandle::close_send`] when done, then -//! [`StreamHandle::await_reply`]. -//! 4. **Non-normal termination must call [`StreamHandle::abort`], not -//! just drop the handle** — the peer's only signal to tell a -//! cancellation/failure apart from a dropped connection -//! (`plans/PLAN_WIRE_PROTOCOL.md` §13.1, point 4). -//! -//! Provider usage: -//! 1. [`Session::advertise`](crate::connection::Session::advertise) once -//! per procedure this session will answer. -//! 2. Loop on [`StreamHandle::accept`], which blocks for the next -//! inbound STREAM_OPEN and hands back a ready-to-use handle plus the -//! parsed [`frame::StreamOpenInfo`] (check its `procedure` — a single -//! connection's dedicated streams aren't partitioned by which -//! procedure they're for, so a session that's advertised more than -//! one needs this to route). -//! 3. Drive it exactly like the caller side, from the opposite chair: -//! `server_stream` mode pushes with `send_data`/`close_send`; -//! `client_stream` mode drains with `recv` and finishes with -//! `send_reply`. - -use std::time::Duration; - -use crate::cbor::Value; -use crate::connection::{FrameStream, RecvFrameError, SendFrameError, Session}; -use crate::control_channel::drop_warning::{self, Kind, Reason, Subject}; -use crate::frame::{self, StreamEncoding, StreamMode, StreamRole}; -use crate::identity::KeyPair; - -pub struct StreamHandle { - stream: FrameStream, - pub stream_id: [u8; 16], - pub mode: StreamMode, - seq_out: u64, -} - -#[derive(Debug)] -pub enum OpenError { - OpenStream(quinn::ConnectionError), - Send(SendFrameError), -} - -impl std::fmt::Display for OpenError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - OpenError::OpenStream(e) => write!(f, "opening a dedicated stream: {e}"), - OpenError::Send(e) => write!(f, "sending stream_open: {e}"), - } - } -} - -impl std::error::Error for OpenError {} - -#[derive(Debug)] -pub enum AcceptError { - AcceptStream(quinn::ConnectionError), - Timeout, - Recv(RecvFrameError), -} - -/// The application error code a refused inbound stream is aborted with, in -/// both directions: RESET_STREAM on its send half and STOP_SENDING on its -/// receive half. The same code in every Macula stack. -pub const REFUSED_STREAM: u32 = 2; - -/// What became of an inbound dedicated stream that didn't open. -enum Inbound { - /// Refused, with nothing written and no handler run. - Refused { - reason: Reason, - subject: Subject, - }, - Failed(AcceptError), -} - -fn refuse(stream: FrameStream, reason: Reason, subject: Subject) -> Inbound { - stream.abort_both(REFUSED_STREAM); - Inbound::Refused { reason, subject } -} - -impl std::fmt::Display for AcceptError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - AcceptError::AcceptStream(e) => write!(f, "accepting a dedicated stream: {e}"), - AcceptError::Timeout => write!(f, "no inbound stream within the given timeout"), - AcceptError::Recv(e) => write!(f, "reading the stream's first frame: {e}"), - } - } -} - -impl std::error::Error for AcceptError {} - -/// One item [`StreamHandle::recv`] hands back: a chunk, or a clean -/// end-of-stream. -#[derive(Debug, Clone)] -pub enum StreamItem { - Data { - seq: u64, - encoding: StreamEncoding, - body: Value, - }, - Eof, -} - -#[derive(Debug)] -pub enum RecvStreamError { - Recv(RecvFrameError), - Parse(frame::ParseStreamEventError), - /// The peer sent an explicit STREAM_ERROR abort. - PeerAborted { - code: String, - message: String, - }, - /// A frame for a *different* stream_id arrived on this stream — - /// never expected on a dedicated stream with a well-behaved peer, - /// surfaced distinctly rather than silently accepted. - StreamIdMismatch, - /// A frame arrived that isn't valid in the context this call is - /// waiting in — e.g. [`StreamHandle::recv`] got a STREAM_REPLY - /// (only [`StreamHandle::await_reply`] expects one), or - /// `await_reply` got a STREAM_DATA/STREAM_END before any reply. - UnexpectedFrame, -} - -impl std::fmt::Display for RecvStreamError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - RecvStreamError::Recv(e) => write!(f, "{e}"), - RecvStreamError::Parse(e) => write!(f, "{e}"), - RecvStreamError::PeerAborted { code, message } => { - write!(f, "peer aborted the stream: {code} ({message})") - } - RecvStreamError::StreamIdMismatch => { - write!(f, "received a frame for a different stream_id") - } - RecvStreamError::UnexpectedFrame => { - write!(f, "received a frame not valid in this context") - } - } - } -} - -impl std::error::Error for RecvStreamError {} - -impl StreamHandle { - /// Open a dedicated stream on `session`'s connection and send a - /// signed STREAM_OPEN. Fire-and-forget at the wire level — no reply - /// is expected here; drive [`recv`](Self::recv) (for - /// `server_stream`/`bidi`) or [`send_data`](Self::send_data) (for - /// `client_stream`/`bidi`) next, depending on `mode`. - pub async fn open( - session: &Session, - procedure: &str, - realm: [u8; 32], - mode: StreamMode, - args: Value, - deadline_ms: i128, - identity: &KeyPair, - ) -> Result { - let stream = session - .open_dedicated_stream() - .await - .map_err(OpenError::OpenStream)?; - Self::open_on(stream, procedure, realm, mode, args, deadline_ms, identity).await - } - - /// [`open`](Self::open), over a dedicated stream already open to the - /// station. - pub(crate) async fn open_on( - mut stream: FrameStream, - procedure: &str, - realm: [u8; 32], - mode: StreamMode, - args: Value, - deadline_ms: i128, - identity: &KeyPair, - ) -> Result { - let stream_id: [u8; 16] = rand::random(); - let spec = frame::StreamOpenSpec::new( - stream_id, - procedure, - realm, - mode, - args, - deadline_ms, - identity.node_id(), - ); - let signed = frame::sign(frame::stream_open(&spec), identity); - stream.send_frame(signed).await.map_err(OpenError::Send)?; - Ok(Self { - stream, - stream_id, - mode, - seq_out: 0, - }) - } - - /// Provider role: block for the next inbound STREAM_OPEN on - /// `session`'s connection, bounded by `timeout`. Only ever succeeds - /// after [`Session::advertise`](crate::connection::Session::advertise) - /// has registered at least one procedure — otherwise the station has - /// nothing to route here. Returns the ready-to-use handle alongside - /// the parsed [`frame::StreamOpenInfo`] (check its `procedure` if - /// this session advertised more than one). - /// - /// The app decides whether to serve a stream it accepts. One it refuses - /// should get a STREAM_ERROR with macula's codes, `unauthorized` when the - /// caller may not use the procedure and `not_found` for a procedure it - /// doesn't serve, sent with [`refuse`](Self::refuse), so a caller sees the - /// same refusal from every stack. - pub async fn accept( - session: &Session, - timeout: Duration, - ) -> Result<(Self, frame::StreamOpenInfo), AcceptError> { - let deadline = tokio::time::Instant::now() + timeout; - loop { - let stream = tokio::time::timeout_at(deadline, session.accept_dedicated_stream()) - .await - .map_err(|_| AcceptError::Timeout)? - .map_err(AcceptError::AcceptStream)?; - match tokio::time::timeout_at(deadline, Self::open_inbound(stream)).await { - Err(_) => return Err(AcceptError::Timeout), - Ok(Ok(opened)) => return Ok(opened), - Ok(Err(Inbound::Refused { reason, subject })) => { - session - .drop_warnings() - .record(Kind::RefusedStreamOpen, reason, subject); - } - Ok(Err(Inbound::Failed(e))) => return Err(e), - } - } - } - - /// Opens `stream`, an inbound dedicated stream, when its first frame is a - /// STREAM_OPEN signed by the caller it names, with that caller in map - /// args as a CALL handler gets it. Any other first frame gets the stream - /// refused before anything else looks at it: both halves are aborted - /// with [`REFUSED_STREAM`] and nothing is written, as macula does. - async fn open_inbound( - mut stream: FrameStream, - ) -> Result<(Self, frame::StreamOpenInfo), Inbound> { - let first = match stream.recv_frame().await { - Ok(first) => first, - Err(RecvFrameError::Decode(_)) => { - return Err(refuse(stream, Reason::Malformed, Subject::Nothing)) - } - Err(e) => return Err(Inbound::Failed(AcceptError::Recv(e))), - }; - if !matches!(first.get("frame_type"), Some(Value::Text(t)) if t == "stream_open") { - return Err(refuse(stream, Reason::NotAStreamOpen, Subject::Nothing)); - } - if let Err(reason) = drop_warning::signed_caller(&first) { - return Err(refuse(stream, reason, drop_warning::procedure_of(&first))); - } - let Ok(mut open) = frame::parse_stream_open(&first) else { - return Err(refuse( - stream, - Reason::Malformed, - drop_warning::procedure_of(&first), - )); - }; - open.args = crate::connection::with_caller(open.args, open.caller); - let handle = Self { - stream, - stream_id: open.stream_id, - mode: open.mode, - seq_out: 0, - }; - Ok((handle, open)) - } - - /// Provider role: send the terminal STREAM_REPLY a `client_stream`/ - /// `bidi` caller's own [`await_reply`](Self::await_reply) is waiting - /// on, once this side has fully consumed and verified whatever the - /// caller streamed. - pub async fn send_reply( - &mut self, - payload: Value, - identity: &KeyPair, - ) -> Result<(), SendFrameError> { - let spec = frame::StreamReplySpec::new(self.stream_id, payload, identity.node_id()); - let signed = frame::sign(frame::stream_reply(&spec), identity); - self.stream.send_frame(signed).await - } - - /// Send one chunk. `seq` is tracked internally, starting at 0 and - /// incrementing per call — matches the reference's `seq_out` counter - /// (a sanity/debugging signal, not used for reordering: frames - /// arrive in order on a single QUIC stream by construction). - pub async fn send_data( - &mut self, - encoding: StreamEncoding, - body: Value, - identity: &KeyPair, - ) -> Result<(), SendFrameError> { - let spec = frame::StreamDataSpec::new( - self.stream_id, - self.seq_out, - encoding, - body, - Some(identity.public_bytes()), - ); - self.seq_out += 1; - let signed = frame::sign(frame::stream_data(&spec), identity); - self.stream.send_frame(signed).await - } - - /// Half-close: signal this side is done sending. For - /// `client_stream`/`bidi` modes, follow with - /// [`await_reply`](Self::await_reply). - pub async fn close_send(&mut self, identity: &KeyPair) -> Result<(), SendFrameError> { - let spec = frame::StreamEndSpec::new( - self.stream_id, - StreamRole::Send, - Some(identity.public_bytes()), - ); - let signed = frame::sign(frame::stream_end(&spec), identity); - self.stream.send_frame(signed).await - } - - /// Receive the next chunk or end-of-stream, bounded by `timeout`. - pub async fn recv(&mut self, timeout: Duration) -> Result { - let value = self - .stream - .recv_frame_timeout(timeout) - .await - .map_err(RecvStreamError::Recv)?; - match frame::parse_stream_event(&value).map_err(RecvStreamError::Parse)? { - frame::StreamEvent::Data { - stream_id, - seq, - encoding, - body, - } => { - self.check_stream_id(stream_id)?; - Ok(StreamItem::Data { - seq, - encoding, - body, - }) - } - frame::StreamEvent::End { stream_id, role: _ } => { - self.check_stream_id(stream_id)?; - Ok(StreamItem::Eof) - } - frame::StreamEvent::Error { - stream_id, - code, - message, - } => { - self.check_stream_id(stream_id)?; - Err(RecvStreamError::PeerAborted { code, message }) - } - frame::StreamEvent::Reply { .. } => Err(RecvStreamError::UnexpectedFrame), - } - } - - /// Block for the provider's terminal STREAM_REPLY (`client_stream`/ - /// `bidi` modes only) — call after [`close_send`](Self::close_send). - pub async fn await_reply( - &mut self, - timeout: Duration, - ) -> Result<(Value, [u8; 32]), RecvStreamError> { - let value = self - .stream - .recv_frame_timeout(timeout) - .await - .map_err(RecvStreamError::Recv)?; - match frame::parse_stream_event(&value).map_err(RecvStreamError::Parse)? { - frame::StreamEvent::Reply { - stream_id, - payload, - responded_by, - } => { - self.check_stream_id(stream_id)?; - Ok((payload, responded_by)) - } - frame::StreamEvent::Error { - stream_id, - code, - message, - } => { - self.check_stream_id(stream_id)?; - Err(RecvStreamError::PeerAborted { code, message }) - } - frame::StreamEvent::Data { .. } | frame::StreamEvent::End { .. } => { - Err(RecvStreamError::UnexpectedFrame) - } - } - } - - fn check_stream_id(&self, stream_id: [u8; 16]) -> Result<(), RecvStreamError> { - if stream_id == self.stream_id { - Ok(()) - } else { - Err(RecvStreamError::StreamIdMismatch) - } - } - - /// Non-normal termination: explicitly tell the peer this stream is - /// aborting, per §13.1 point 4 — the only signal the peer gets to - /// distinguish a cancellation/failure from a dropped connection. - /// Best-effort, like [`Session::close`](crate::connection::Session::close)'s - /// GOODBYE — consumes `self` so the handle can't be used again after - /// aborting. - pub async fn abort( - mut self, - code: impl Into, - message: impl Into, - identity: &KeyPair, - ) { - let spec = frame::StreamErrorSpec::new( - self.stream_id, - code, - message, - Some(identity.public_bytes()), - ); - let signed = frame::sign(frame::stream_error(&spec), identity); - let _ = self.stream.send_frame(signed).await; - } - - /// Refuses a stream this provider accepted and won't serve: writes a - /// STREAM_ERROR with `code` and `message`, macula's `unauthorized` or - /// `not_found`, then finishes the send half so the error reaches the caller - /// and stops reading with [`REFUSED_STREAM`]. When the STREAM_ERROR can't - /// be written, the stream is aborted in both directions with - /// [`REFUSED_STREAM`] instead. - pub async fn refuse( - mut self, - code: impl Into, - message: impl Into, - identity: &KeyPair, - ) -> Result<(), SendFrameError> { - let spec = frame::StreamErrorSpec::new( - self.stream_id, - code, - message, - Some(identity.public_bytes()), - ); - let signed = frame::sign(frame::stream_error(&spec), identity); - if let Err(e) = self.stream.send_frame(signed).await { - self.stream.abort_both(REFUSED_STREAM); - return Err(e); - } - self.stream.finish_and_stop_reading(REFUSED_STREAM); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - //! An inbound dedicated stream over a real QUIC connection to a local - //! endpoint, so a refusal's abort codes reach the opener as they would - //! from a station. The names match the Go, .NET and Erlang tests. - use super::*; - use crate::transport::Trust; - - const REALM: [u8; 32] = [7; 32]; - - /// A connection to a local endpoint, the endpoint's side of it, and the - /// endpoint, which has to outlive both. - async fn local_connection() -> (quinn::Connection, quinn::Connection, quinn::Endpoint) { - let key_pair = rcgen::KeyPair::generate().expect("a key pair"); - let certificate = rcgen::CertificateParams::new(vec!["localhost".to_string()]) - .expect("certificate params") - .self_signed(&key_pair) - .expect("a self-signed certificate"); - let key = rustls::pki_types::PrivateKeyDer::Pkcs8(key_pair.serialize_der().into()); - let mut crypto = macula_pqc::server_builder() - .with_no_client_auth() - .with_single_cert(vec![certificate.der().clone()], key) - .expect("a server certificate"); - // The client only talks to a peer that speaks macula's ALPN protocol. - crypto.alpn_protocols = vec![crate::transport::ALPN.to_vec()]; - let config = quinn::ServerConfig::with_crypto(std::sync::Arc::new( - quinn::crypto::rustls::QuicServerConfig::try_from(crypto) - .expect("a QUIC server config"), - )); - let endpoint = - quinn::Endpoint::server(config, ([127, 0, 0, 1], 0).into()).expect("a local endpoint"); - let port = endpoint.local_addr().expect("a local address").port(); - let (opener, provider) = tokio::join!( - crate::transport::connect("127.0.0.1", port, Trust::Insecure), - async { - endpoint - .accept() - .await - .expect("an incoming connection") - .await - .expect("the connection completes") - } - ); - (opener.expect("the opener connects"), provider, endpoint) - } - - /// Opens a stream from `opener` that starts with `first`, and takes the - /// provider's side of it. - async fn opened_with( - opener: &quinn::Connection, - provider: &quinn::Connection, - first: &[u8], - ) -> (quinn::SendStream, quinn::RecvStream, FrameStream) { - let (mut send, recv) = opener.open_bi().await.expect("a stream opens"); - send.write_all(first) - .await - .expect("the first bytes are written"); - let (provider_send, provider_recv) = - provider.accept_bi().await.expect("the stream arrives"); - (send, recv, FrameStream::new(provider_send, provider_recv)) - } - - fn stream_open(caller: &KeyPair, args: Value) -> Value { - frame::stream_open(&frame::StreamOpenSpec::new( - rand::random(), - "app/stream", - REALM, - StreamMode::ServerStream, - args, - 0, - caller.node_id(), - )) - } - - fn encoded(frame: &Value) -> Vec { - frame::encode(frame).expect("the frame encodes") - } - - #[tokio::test] - async fn a_stream_open_not_signed_by_its_caller_is_refused() { - let (opener, provider, _endpoint) = local_connection().await; - let caller = KeyPair::generate(); - let refused_code = quinn::VarInt::from_u32(REFUSED_STREAM); - let first_frames = [ - ( - encoded(&frame::sign( - stream_open(&caller, Value::Null), - &KeyPair::generate(), - )), - Reason::InvalidSignature, - ), - ( - encoded(&stream_open(&caller, Value::Null)), - Reason::Unsigned, - ), - ( - encoded(&Value::Map(vec![( - Value::text("frame_type"), - Value::text("call"), - )])), - Reason::NotAStreamOpen, - ), - // A one-byte frame whose CBOR initial byte uses a reserved value. - (vec![0, 0, 0, 1, 0x1C], Reason::Malformed), - ]; - - for (first, expected) in first_frames { - let (send, mut recv, inbound) = opened_with(&opener, &provider, &first).await; - - let refused = StreamHandle::open_inbound(inbound).await; - - assert!( - matches!(refused, Err(Inbound::Refused { reason, .. }) if reason == expected), - "expected the stream refused as {expected:?}" - ); - assert!(matches!(send.stopped().await, Ok(Some(code)) if code == refused_code)); - assert!(matches!( - recv.read(&mut [0; 1]).await, - Err(quinn::ReadError::Reset(code)) if code == refused_code - )); - } - - let genuine = encoded(&frame::sign(stream_open(&caller, Value::Null), &caller)); - let (_send, _recv, inbound) = opened_with(&opener, &provider, &genuine).await; - let opened = StreamHandle::open_inbound(inbound).await; - assert!(matches!(opened, Ok((_, ref info)) if info.caller == caller.node_id())); - } - - #[tokio::test] - async fn a_refused_stream_writes_its_error_then_finishes_and_stops_reading() { - let (opener, provider, _endpoint) = local_connection().await; - let caller = KeyPair::generate(); - let first = encoded(&frame::sign(stream_open(&caller, Value::Null), &caller)); - let (send, recv, inbound) = opened_with(&opener, &provider, &first).await; - let Ok((handle, _)) = StreamHandle::open_inbound(inbound).await else { - panic!("a STREAM_OPEN signed by its caller opens"); - }; - let stopped = send.stopped(); - - handle - .refuse( - "unauthorized", - "not authorized for this procedure", - &KeyPair::generate(), - ) - .await - .expect("the STREAM_ERROR is written"); - - let mut opener_side = FrameStream::new(send, recv); - let error = opener_side - .recv_frame() - .await - .expect("the STREAM_ERROR arrives"); - assert!(matches!( - frame::parse_stream_event(&error), - Ok(frame::StreamEvent::Error { ref code, ref message, .. }) - if code == "unauthorized" && message == "not authorized for this procedure" - )); - assert!( - matches!( - opener_side.recv_frame().await, - Err(RecvFrameError::StreamClosed) - ), - "the send half is finished, not reset" - ); - assert!(matches!( - stopped.await, - Ok(Some(code)) if code == quinn::VarInt::from_u32(REFUSED_STREAM) - )); - } - - #[tokio::test] - async fn a_stream_open_threads_its_caller_into_the_args() { - let (opener, provider, _endpoint) = local_connection().await; - let caller = KeyPair::generate(); - let claimed = KeyPair::generate().node_id(); - let args = Value::Map(vec![ - (Value::text("n"), Value::Int(21)), - (Value::text("caller"), Value::Bytes(claimed.to_vec())), - ]); - let first = encoded(&frame::sign(stream_open(&caller, args), &caller)); - let (_send, _recv, inbound) = opened_with(&opener, &provider, &first).await; - - let Ok((_, info)) = StreamHandle::open_inbound(inbound).await else { - panic!("a STREAM_OPEN signed by its caller opens"); - }; - - assert_eq!( - info.args.get("caller"), - Some(&Value::Bytes(caller.node_id().to_vec())) - ); - assert_eq!(info.args.get("n"), Some(&Value::Int(21))); - } -} diff --git a/src/transport.rs b/src/transport.rs index 4907b67..fbb31b7 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -1,323 +1,340 @@ -//! QUIC transport: dialing a macula-station, ported from -//! `native/macula_quic/src/config.rs` (`macula-io/macula`). +//! Dialing a macula 12 station over QUIC, as macula-go's `transport` does. //! -//! Raw QUIC (RFC 9000), not real HTTP/3 despite the "HTTP/3 mesh" -//! branding elsewhere — ALPN is the plain string `"macula"`, and macula's -//! own application framing rides directly on QUIC streams. `quinn` is -//! the exact QUIC engine macula-station's own `native/macula_quic` NIF -//! already runs, so this is wire-compatible by construction, not by -//! coincidence. +//! Raw QUIC (RFC 9000) with the ALPN `"macula"`, TLS 1.3 only, and the key +//! exchange macula-pqc fixes: SecP384r1MLKEM1024, then SecP256r1MLKEM768, and +//! nothing classical. A station's certificate is self-signed, so it is not +//! checked against a CA: macula-pqc's [`KeyPossessionVerifier`] accepts +//! exactly one certificate whose key is ML-DSA-87, then the station's +//! handshake signature under that key. That proves the station holds the key, +//! not who it is: the handshake then checks the station's TLS binding, which +//! ties this leaf to the identity key whose node_id the target pins (see +//! `crate::handshake`). A target without an expected node_id is refused before +//! anything is dialed. +//! +//! quinn protects QUIC Initial packets with the suite it finds in the rustls +//! provider, and RFC 9001 fixes that suite at AES-128-GCM, which macula-pqc's +//! provider does not offer for the handshake itself; the configuration is +//! therefore built with `with_initial` and macula-pqc's `quic_initial_suite`. use std::net::{SocketAddr, ToSocketAddrs}; use std::sync::Arc; use std::time::Duration; +use macula_pqc::KeyPossessionVerifier; +use quinn::crypto::rustls::QuicClientConfig; use quinn::{ClientConfig, Endpoint, IdleTimeout, TransportConfig}; -use crate::cert::{PubkeyPinVerifier, SkipServerVerification}; +use crate::profile::Profile; -/// The ALPN macula-station listens for. Not `"h3"` — see the module doc. +/// The ALPN macula stations listen for. pub const ALPN: &[u8] = b"macula"; -/// Matches macula's own defaults (`macula_quic.erl`'s `idle_timeout_ms` / -/// `keep_alive_interval_ms`): long enough to tolerate a real gap between -/// frames without closing the connection, with keepalive pings sent -/// often enough (~10x within the idle window) that a healthy connection -/// is never mistaken for a dead one. -pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(300); -pub const DEFAULT_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(15); +/// macula's QUIC idle timeout and keep-alive: long enough to tolerate a real +/// gap between frames, with pings often enough that a healthy connection is +/// never mistaken for a dead one. +pub const IDLE_TIMEOUT: Duration = Duration::from_secs(300); +pub const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(15); + +/// A station to dial: where it listens, the profile the node runs, and the +/// node_id the station must prove in the handshake. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Target { + pub host: String, + pub port: u16, + pub profile: Profile, + pub expected_node_id: [u8; 32], +} -/// How to trust whatever certificate the station presents. Mirrors the -/// three modes `macula_quic`'s own `build_client_config` supports — see -/// `plans/PLAN_WIRE_PROTOCOL.md` §2. -/// -/// `Clone, Copy`: every variant is plain data (a bare `[u8; 32]` or -/// nothing at all), and `pool.rs` needs to redial a link — possibly -/// under a DIFFERENT per-link trust than the pool's own configured -/// default, see `pool::PooledLink`'s own doc — more than once over a -/// link's lifetime (initial dial, every respawn). -#[derive(Clone, Copy)] -pub enum Trust { - /// Pin the station's known Ed25519 pubkey (its macula NodeId). The - /// right mode once a station's identity is known — DHT-resolved, or - /// configured directly, which is the normal case for a mobile client - /// dialing a known station. - Pinned([u8; 32]), - /// Standard CA-bundle + hostname validation, for a station whose TLS - /// is terminated by real PKI (e.g. Let's Encrypt) rather than a - /// self-signed macula identity cert. - WebPki, - /// Skip verification entirely. **Development/diagnostic only** — see - /// [`crate::cert::SkipServerVerification`]'s own warning. - Insecure, +/// A dialed station: the QUIC connection, the endpoint it runs on, the leaf +/// certificate the station presented (DER), and the target it was dialed as. +/// The endpoint must live as long as the connection. +pub struct Dialed { + pub connection: quinn::Connection, + pub endpoint: Endpoint, + pub leaf: Vec, + pub target: Target, } +/// Why a dial failed. #[derive(Debug)] -pub enum ConnectError { +pub enum DialError { + /// The target names no expected node_id. + NoExpectedNodeId, + /// The host resolved to no address, or not at all. Resolve(std::io::Error), - NoAddress, + /// The local endpoint could not be made. Endpoint(std::io::Error), - Config(rustls::Error), + /// The TLS or QUIC configuration could not be built. + Config(String), + /// The connection could not be started. Connect(quinn::ConnectError), + /// The QUIC or TLS handshake failed, the station's certificate among the + /// reasons: not exactly one, or not an ML-DSA-87 key. Connection(quinn::ConnectionError), + /// The station presented no certificate the connection could hand back. + NoLeaf, } -impl std::fmt::Display for ConnectError { +impl std::fmt::Display for DialError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - ConnectError::Resolve(e) => write!(f, "resolving station address: {e}"), - ConnectError::NoAddress => write!(f, "hostname resolved to no addresses"), - ConnectError::Endpoint(e) => write!(f, "creating QUIC endpoint: {e}"), - ConnectError::Config(e) => write!(f, "building TLS config: {e}"), - ConnectError::Connect(e) => write!(f, "starting QUIC connect: {e}"), - ConnectError::Connection(e) => write!(f, "QUIC connection failed: {e}"), + DialError::NoExpectedNodeId => f.write_str("the dial target names no expected node_id"), + DialError::Resolve(e) => write!(f, "resolving the station's address: {e}"), + DialError::Endpoint(e) => write!(f, "creating the QUIC endpoint: {e}"), + DialError::Config(e) => write!(f, "building the TLS configuration: {e}"), + DialError::Connect(e) => write!(f, "starting the QUIC connection: {e}"), + DialError::Connection(e) => write!(f, "the QUIC connection failed: {e}"), + DialError::NoLeaf => f.write_str("the station presented no certificate"), } } } -impl std::error::Error for ConnectError {} +impl std::error::Error for DialError {} -fn client_config(trust: Trust) -> Result { - let crypto = tls_client_config(trust); +/// Dials `target`: QUIC and TLS 1.3 with the post-quantum key exchange, +/// the station's certificate checked for an ML-DSA-87 key it holds. +pub async fn dial_target(target: &Target) -> Result { + if target.expected_node_id == [0u8; 32] { + return Err(DialError::NoExpectedNodeId); + } + let addr = (target.host.as_str(), target.port) + .to_socket_addrs() + .map_err(DialError::Resolve)? + .next() + .ok_or_else(|| DialError::Resolve(std::io::Error::other("no address")))?; + let bind: SocketAddr = if addr.is_ipv6() { + (std::net::Ipv6Addr::UNSPECIFIED, 0).into() + } else { + (std::net::Ipv4Addr::UNSPECIFIED, 0).into() + }; + let mut endpoint = Endpoint::client(bind).map_err(DialError::Endpoint)?; + endpoint.set_default_client_config(client_config()?); + let connection = endpoint + .connect(addr, &target.host) + .map_err(DialError::Connect)? + .await + .map_err(DialError::Connection)?; + let leaf = connection + .peer_identity() + .and_then(|identity| { + identity + .downcast::>>() + .ok() + }) + .and_then(|chain| chain.first().map(|leaf| leaf.to_vec())) + .ok_or(DialError::NoLeaf)?; + Ok(Dialed { + connection, + endpoint, + leaf, + target: target.clone(), + }) +} +/// The QUIC client configuration every dial uses. +fn client_config() -> Result { + let quic = QuicClientConfig::with_initial( + Arc::new(tls_client_config()?), + macula_pqc::quic_initial_suite(), + ) + .map_err(|e| DialError::Config(e.to_string()))?; let mut transport = TransportConfig::default(); transport.max_idle_timeout(Some( - IdleTimeout::try_from(DEFAULT_IDLE_TIMEOUT).expect("valid idle timeout"), + IdleTimeout::try_from(IDLE_TIMEOUT).map_err(|e| DialError::Config(e.to_string()))?, )); - transport.keep_alive_interval(Some(DEFAULT_KEEP_ALIVE_INTERVAL)); - apply_flow_control_defaults(&mut transport); - - let quic_crypto = quinn::crypto::rustls::QuicClientConfig::try_from(crypto) - .map_err(|e| rustls::Error::General(e.to_string()))?; - let mut config = ClientConfig::new(Arc::new(quic_crypto)); - config.transport_config(Arc::new(transport)); - Ok(config) -} - -/// The rustls half of a dial's configuration, before quinn wraps it: a -/// seam, so the tests drive the configuration this crate actually dials -/// with rather than a copy built the same way. -/// -/// The key exchange is `macula-pqc`'s: its builder arrives with the groups -/// and TLS 1.3 fixed, and keeps its provider where nothing here can edit -/// it. A station offering only classical groups, as macula 11.5.0 and -/// earlier do, cannot be reached. -pub(crate) fn tls_client_config(trust: Trust) -> rustls::ClientConfig { - let builder = macula_pqc::client_builder; - let mut crypto = match trust { - Trust::Pinned(pubkey) => builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(PubkeyPinVerifier::new(pubkey))) - .with_no_client_auth(), - Trust::WebPki => { - let mut roots = rustls::RootCertStore::empty(); - roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - builder() - .with_root_certificates(roots) - .with_no_client_auth() - } - Trust::Insecure => builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(SkipServerVerification::new())) - .with_no_client_auth(), - }; - crypto.alpn_protocols = vec![ALPN.to_vec()]; - crypto -} - -/// Matches macula's own `apply_flow_control_defaults` in -/// `native/macula_quic/src/config.rs`: default Quinn flow-control -/// windows are conservative enough to bottleneck a connection carrying -/// many small signed frames, well before either side's application-level -/// backpressure kicks in. -fn apply_flow_control_defaults(transport: &mut TransportConfig) { + transport.keep_alive_interval(Some(KEEP_ALIVE_INTERVAL)); transport.stream_receive_window((16u32 * 1024 * 1024).into()); transport.receive_window((64u32 * 1024 * 1024).into()); - transport.send_window(64u64 * 1024 * 1024); -} - -/// Dial a macula-station at `host:port` over QUIC with the given trust -/// mode, completing the QUIC/TLS handshake (ALPN negotiation included). -/// This is transport-only — it does **not** send or expect any macula -/// application frame (CONNECT/HELLO); see `plans/PLAN_WIRE_PROTOCOL.md` -/// §3 for what happens on top of this connection. -pub async fn connect( - host: &str, - port: u16, - trust: Trust, -) -> Result { - let addr = resolve(host, port)?; - let bind_addr: SocketAddr = if addr.is_ipv6() { - "[::]:0".parse().expect("valid unspecified v6 addr") - } else { - "0.0.0.0:0".parse().expect("valid unspecified v4 addr") - }; - - let mut endpoint = Endpoint::client(bind_addr).map_err(ConnectError::Endpoint)?; - let config = client_config(trust).map_err(ConnectError::Config)?; - endpoint.set_default_client_config(config); - - let connecting = endpoint - .connect(addr, host) - .map_err(ConnectError::Connect)?; - connecting.await.map_err(ConnectError::Connection) + transport.send_window(64 * 1024 * 1024); + let mut config = ClientConfig::new(Arc::new(quic)); + config.transport_config(Arc::new(transport)); + Ok(config) } -fn resolve(host: &str, port: u16) -> Result { - (host, port) - .to_socket_addrs() - .map_err(ConnectError::Resolve)? - .next() - .ok_or(ConnectError::NoAddress) +/// The rustls half of a dial: macula-pqc's client builder, its key possession +/// verifier, the ALPN, and no session resumption. +pub(crate) fn tls_client_config() -> Result { + let verifier = KeyPossessionVerifier::new(); + let mut config = macula_pqc::client_builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(verifier)) + .with_no_client_auth(); + config.alpn_protocols = vec![ALPN.to_vec()]; + config.resumption = rustls::client::Resumption::disabled(); + config.enable_early_data = false; + Ok(config) } #[cfg(test)] mod tests { - //! The key exchange this crate dials with, asserted on real TLS 1.3 - //! handshakes against the configuration `connect` actually uses. + //! The dial, on real QUIC against local stations: one as a macula 12 + //! station is (macula-pqc, an ML-DSA-87 certificate), and the ones a dial + //! must refuse. use std::sync::Arc; + use quinn::crypto::rustls::QuicServerConfig; use rustls::crypto::aws_lc_rs::kx_group as aws; use rustls::crypto::CryptoProvider; - use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; - use rustls::{ - ClientConnection, ConfigBuilder, Connection, NamedGroup, ServerConfig, ServerConnection, - WantsVerifier, - }; + use rustls::pki_types::{CertificateDer, PrivateKeyDer}; + use rustls::{NamedGroup, ServerConfig}; - use super::{tls_client_config, Trust, ALPN}; + use super::{dial_target, tls_client_config, DialError, Target, ALPN}; + use crate::profile::Profile; - /// `SecP384r1MLKEM1024`, code point `0x11ED`. rustls has no variant for - /// it and no rustls provider ships it: only `macula-pqc` does. + /// SecP384r1MLKEM1024, code point 0x11ED. const SECP384R1MLKEM1024: NamedGroup = NamedGroup::Unknown(0x11ED); - fn offered(provider: &CryptoProvider) -> Vec { - provider.kx_groups.iter().map(|g| g.name()).collect() + fn mldsa_certificate() -> (CertificateDer<'static>, PrivateKeyDer<'static>) { + let (certificate, key) = + macula_pqc::self_signed_certificate(&[7u8; 32], vec!["localhost".to_string()]) + .expect("a certificate"); + (certificate, key.into()) } - /// Every trust mode dials with `macula-pqc`'s two groups and nothing - /// else. A mode that built its own provider would be the one path a - /// classical group could come back through. - #[test] - fn every_trust_mode_offers_exactly_macula_pqcs_groups() { - for (mode, trust) in [ - ("pinned", Trust::Pinned([7; 32])), - ("webpki", Trust::WebPki), - ("insecure", Trust::Insecure), - ] { - assert_eq!( - offered(tls_client_config(trust).crypto_provider()), - vec![SECP384R1MLKEM1024, NamedGroup::secp256r1MLKEM768], - "{mode}" - ); - } + fn classical_certificate() -> (CertificateDer<'static>, PrivateKeyDer<'static>) { + let key_pair = rcgen::KeyPair::generate().expect("a key pair"); + let certificate = rcgen::CertificateParams::new(vec!["localhost".to_string()]) + .expect("certificate params") + .self_signed(&key_pair) + .expect("a certificate"); + ( + certificate.der().clone(), + PrivateKeyDer::Pkcs8(key_pair.serialize_der().into()), + ) } - /// A station on `macula-pqc`, as macula's own QUIC NIF now is. - #[test] - fn a_station_on_macula_pqc_negotiates_secp384r1mlkem1024() { - let station = station(macula_pqc::server_builder()); - let agreed = handshake(tls_client_config(Trust::Insecure), station); - assert_eq!(agreed, Ok(SECP384R1MLKEM1024)); + /// A station on 127.0.0.1, serving `config` over QUIC; its endpoint and + /// port. + fn station(mut config: ServerConfig, alpn: &[u8]) -> (quinn::Endpoint, u16) { + config.alpn_protocols = vec![alpn.to_vec()]; + let quic = + QuicServerConfig::with_initial(Arc::new(config), macula_pqc::quic_initial_suite()) + .expect("a QUIC server config"); + let endpoint = quinn::Endpoint::server( + quinn::ServerConfig::with_crypto(Arc::new(quic)), + ([127, 0, 0, 1], 0).into(), + ) + .expect("an endpoint"); + let port = endpoint.local_addr().expect("an address").port(); + let accepting = endpoint.clone(); + tokio::spawn(async move { + while let Some(incoming) = accepting.accept().await { + tokio::spawn(async move { + if let Ok(connection) = incoming.await { + connection.closed().await; + } + }); + } + }); + (endpoint, port) } - /// A station on `aws-lc-rs`'s post-quantum groups: `macula-pqc`'s ML-KEM - /// against `aws-lc-rs`'s, on the group both offer. - #[test] - fn a_station_on_aws_lc_rs_post_quantum_groups_negotiates_secp256r1mlkem768() { - let list = CryptoProvider { - kx_groups: vec![ - aws::SECP256R1MLKEM768, - aws::X25519MLKEM768, - aws::MLKEM1024, - aws::MLKEM768, - ], - ..rustls::crypto::aws_lc_rs::default_provider() - }; - let agreed = handshake(tls_client_config(Trust::Insecure), station_on(list)); - assert_eq!(agreed, Ok(NamedGroup::secp256r1MLKEM768)); + fn macula_station() -> (quinn::Endpoint, u16, CertificateDer<'static>) { + let (certificate, key) = mldsa_certificate(); + let config = macula_pqc::server_builder() + .with_no_client_auth() + .with_single_cert(vec![certificate.clone()], key) + .expect("a station configuration"); + let (endpoint, port) = station(config, ALPN); + (endpoint, port, certificate) + } + + fn target(port: u16) -> Target { + Target { + host: "127.0.0.1".to_string(), + port, + profile: Profile::PqHybrid, + expected_node_id: [1u8; 32], + } } - /// ⛔ THE NEGATIVE CONTROL. A station offering only classical groups, as - /// macula 11.5.0 and earlier do, must NOT agree with this dialler. It - /// can only fail to agree if the dialler's group list is in force. #[test] - fn a_classical_only_station_cannot_agree_with_us() { - let classical = CryptoProvider { - kx_groups: vec![aws::X25519, aws::SECP256R1, aws::SECP384R1], - ..rustls::crypto::aws_lc_rs::default_provider() - }; - let outcome = handshake(tls_client_config(Trust::Insecure), station_on(classical)); - assert!( - outcome.is_err(), - "a classical-only station agreed with us: {outcome:?}" + fn a_dial_offers_exactly_macula_pqcs_groups() { + let config = tls_client_config().expect("a configuration"); + let offered: Vec = config + .crypto_provider() + .kx_groups + .iter() + .map(|g| g.name()) + .collect(); + assert_eq!( + offered, + vec![SECP384R1MLKEM1024, NamedGroup::secp256r1MLKEM768] ); } - fn identity() -> (Vec>, PrivateKeyDer<'static>) { - let key_pair = rcgen::KeyPair::generate().expect("a key pair"); - let certificate = rcgen::CertificateParams::new(vec!["localhost".to_string()]) - .expect("certificate params") - .self_signed(&key_pair) - .expect("a self-signed certificate"); - let key = PrivateKeyDer::Pkcs8(key_pair.serialize_der().into()); - (vec![certificate.der().clone()], key) + #[tokio::test] + async fn a_macula_12_station_is_reached_and_its_leaf_handed_back() { + let (_station, port, certificate) = macula_station(); + let dialed = dial_target(&target(port)) + .await + .expect("the station is reached"); + assert_eq!(dialed.leaf, certificate.to_vec()); + dialed.connection.close(0u32.into(), b"done"); } - fn station(builder: ConfigBuilder) -> ServerConfig { - let (chain, key) = identity(); - let mut config = builder - .with_no_client_auth() - .with_single_cert(chain, key) - .expect("a server certificate"); - config.alpn_protocols = vec![ALPN.to_vec()]; - config + #[tokio::test] + async fn a_target_without_an_expected_node_id_is_refused_before_dialing() { + let mut unpinned = target(9); + unpinned.expected_node_id = [0u8; 32]; + assert!(matches!( + dial_target(&unpinned).await, + Err(DialError::NoExpectedNodeId) + )); } - fn station_on(provider: CryptoProvider) -> ServerConfig { - station( - ServerConfig::builder_with_provider(Arc::new(provider)) - .with_safe_default_protocol_versions() - .expect("versions"), - ) + #[tokio::test] + async fn a_station_with_a_classical_certificate_is_refused() { + let (certificate, key) = classical_certificate(); + let provider = CryptoProvider { + kx_groups: vec![aws::SECP256R1MLKEM768], + ..rustls::crypto::aws_lc_rs::default_provider() + }; + let config = ServerConfig::builder_with_provider(Arc::new(provider)) + .with_protocol_versions(&[&rustls::version::TLS13]) + .expect("TLS 1.3") + .with_no_client_auth() + .with_single_cert(vec![certificate], key) + .expect("a station configuration"); + let (_station, port) = station(config, ALPN); + assert!(matches!( + dial_target(&target(port)).await, + Err(DialError::Connection(_)) + )); } - /// One in-memory handshake; the group both sides agreed on, or why - /// they did not. A failure to agree is what the negative control asserts. - fn handshake(client: rustls::ClientConfig, server: ServerConfig) -> Result { - let name = ServerName::try_from("localhost").expect("a server name"); - let mut client = Connection::Client( - ClientConnection::new(Arc::new(client), name).map_err(|e| e.to_string())?, - ); - let mut server = - Connection::Server(ServerConnection::new(Arc::new(server)).map_err(|e| e.to_string())?); - for _ in 0..20 { - let moved = pump(&mut client, &mut server)? + pump(&mut server, &mut client)?; - if moved == 0 && !client.is_handshaking() && !server.is_handshaking() { - break; - } - } - if client.is_handshaking() || server.is_handshaking() { - return Err("handshake never completed".to_string()); - } - let agreed = |c: &Connection| c.negotiated_key_exchange_group().map(|g| g.name()); - match (agreed(&client), agreed(&server)) { - (Some(c), Some(s)) if c == s => Ok(c), - other => Err(format!("the two sides disagree on the group: {other:?}")), - } + #[tokio::test] + async fn a_classical_only_station_is_refused() { + let (certificate, key) = classical_certificate(); + let provider = CryptoProvider { + kx_groups: vec![aws::X25519, aws::SECP256R1, aws::SECP384R1], + ..rustls::crypto::aws_lc_rs::default_provider() + }; + let config = ServerConfig::builder_with_provider(Arc::new(provider)) + .with_protocol_versions(&[&rustls::version::TLS13]) + .expect("TLS 1.3") + .with_no_client_auth() + .with_single_cert(vec![certificate], key) + .expect("a station configuration"); + let (_station, port) = station(config, ALPN); + assert!(matches!( + dial_target(&target(port)).await, + Err(DialError::Connection(_)) + )); } - fn pump(from: &mut Connection, to: &mut Connection) -> Result { - let mut buf = Vec::new(); - while from.wants_write() { - from.write_tls(&mut buf).map_err(|e| e.to_string())?; - } - let mut cursor = std::io::Cursor::new(&buf[..]); - while (cursor.position() as usize) < buf.len() { - to.read_tls(&mut cursor).map_err(|e| e.to_string())?; - to.process_new_packets().map_err(|e| e.to_string())?; - } - Ok(buf.len()) + #[tokio::test] + async fn a_station_that_does_not_speak_macula_is_refused() { + let (certificate, key) = mldsa_certificate(); + let config = macula_pqc::server_builder() + .with_no_client_auth() + .with_single_cert(vec![certificate], key) + .expect("a station configuration"); + let (_station, port) = station(config, b"h3"); + assert!(matches!( + dial_target(&target(port)).await, + Err(DialError::Connection(_)) + )); } } diff --git a/src/ucan.rs b/src/ucan.rs deleted file mode 100644 index da7dd1e..0000000 --- a/src/ucan.rs +++ /dev/null @@ -1,659 +0,0 @@ -//! Macula's UCAN (User Controlled Authorization Networks) tokens: -//! creation, verification, and introspection, plus the policy layer a -//! provider gates an inbound CALL through. -//! -//! Ported from `macula-io/macula`'s `src/auth/macula_ucan_nif.erl` and its -//! native Rust NIF (`native/macula_ucan_nif/src/lib.rs`) — both hand-roll a -//! JWT-shaped token (`header.payload.signature`, base64url-no-pad), EdDSA -//! over Ed25519, UCAN spec version `"0.10.0"` (the older JWT-based draft; -//! **not** the current non-JWT/IPLD UCAN 1.0 spec). Confirmed directly by -//! reading the NIF's own `Cargo.toml`: no UCAN-spec crate is depended on at -//! all, only generic `ed25519-dalek`/`serde_json`/`base64`/`sha2` — because -//! no library implements 0.10.0 (the only actively maintained Rust/Go UCAN -//! libraries target the incompatible 1.0.0-rc.1 CBOR/IPLD format, per -//! `macula-go`'s own `ucan` package doc, which made the identical -//! choice porting this same reference). This module does the same: hand- -//! rolled on the crypto/serialization primitives already in this crate -//! (`ed25519-dalek` via [`crate::identity`], plus `serde`/`serde_json`/ -//! `base64` added for this module), matching the reference exactly rather -//! than adopting an incompatible library. -//! -//! A token minted here verifies against `macula-go`'s `ucan` package, -//! the Erlang macula SDK, or vice versa — same header shape, same payload -//! field names (`iss`/`aud`/`exp`/`nbf`/`nnc`/`cap`/`fct`/`prf`), same -//! signing input (`header_b64 + "." + payload_b64`), same algorithm. Field -//! ORDER in the JSON is not part of the compatibility contract (a verifier -//! decodes into a struct, never re-encodes and compares bytes) — only the -//! field NAMES and the exact bytes signed matter. -//! -//! Cross-referenced against `macula-go/ucan/{ucan,policy}.go`, itself -//! independently verified against this same Erlang/Rust reference earlier -//! this session — the two ports should stay behaviorally identical. - -use std::collections::HashMap; -use std::time::{SystemTime, UNIX_EPOCH}; - -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; -use serde::{Deserialize, Serialize}; - -use crate::identity::{verify as identity_verify, KeyPair}; - -const ALG: &str = "EdDSA"; -const TYP: &str = "JWT"; -const UCV: &str = "0.10.0"; - -/// Errors from token creation, decoding, or verification. -#[derive(Debug)] -pub enum UcanError { - /// Not a well-formed `header.payload.signature` triple, or a part - /// isn't valid base64url/JSON — mirrors `macula_ucan_nif`'s - /// `{error, invalid_token}`. - InvalidToken, - /// The token parsed fine but its signature does not verify against - /// the given public key — mirrors `{error, invalid_signature}`. - InvalidSignature, - /// The supplied public key isn't a 32-byte Ed25519 key — mirrors - /// `{error, invalid_public_key}`. - InvalidPublicKey, - /// The token's `exp` claim is in the past — mirrors `{error, expired}`. - Expired, - /// The token's `nbf` claim is in the future — mirrors - /// `{error, not_yet_valid}`. - NotYetValid, - /// A UCAN-gated procedure was called with no token at all — mirrors - /// `macula_station_link.erl`'s `check_ucan(<<>>, _) -> unauthorized` - /// clause (an empty/absent token is refused before ever attempting to - /// verify anything). - NoToken, - /// A UCAN-gated procedure was called with no caller to bind the token - /// to; a missing caller is unauthorized. - NoCaller, - /// The token's `aud` is not the presenting caller's node id as - /// lowercase hex, so it was minted for someone else. - WrongAudience, -} - -impl std::fmt::Display for UcanError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - UcanError::InvalidToken => write!(f, "ucan: invalid token"), - UcanError::InvalidSignature => write!(f, "ucan: invalid signature"), - UcanError::InvalidPublicKey => write!(f, "ucan: invalid public key"), - UcanError::Expired => write!(f, "ucan: token expired"), - UcanError::NotYetValid => write!(f, "ucan: token not yet valid"), - UcanError::NoToken => write!(f, "ucan: no token presented for a gated procedure"), - UcanError::NoCaller => { - write!(f, "ucan: no caller to check the token's audience against") - } - UcanError::WrongAudience => { - write!(f, "ucan: token audience is not the caller presenting it") - } - } - } -} - -impl std::error::Error for UcanError {} - -/// One entry in a UCAN token's capability list — mirrors -/// `macula_ucan_nif`'s `capability() :: #{with := binary(), can := binary()}`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Capability { - pub with: String, - pub can: String, -} - -#[derive(Serialize, Deserialize)] -struct Header { - alg: String, - typ: String, - ucv: String, -} - -/// The JSON shape actually signed/transmitted. Field names match the -/// reference exactly. -#[derive(Serialize, Deserialize)] -struct WirePayload { - iss: String, - aud: String, - #[serde(skip_serializing_if = "Option::is_none")] - exp: Option, - #[serde(skip_serializing_if = "Option::is_none")] - nbf: Option, - #[serde(skip_serializing_if = "Option::is_none")] - nnc: Option, - cap: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - fct: Option>, - prf: Vec, -} - -/// A UCAN token's decoded claims — the Rust-idiomatic counterpart to -/// `WirePayload`, returned from [`decode`]/[`verify`]. -#[derive(Debug, Clone, PartialEq)] -pub struct Payload { - pub issuer: String, - pub audience: String, - pub capabilities: Vec, - pub expires_at: Option, - pub not_before: Option, - pub nonce: String, - pub facts: Option>, - pub proofs: Vec, -} - -/// Optional claims for [`create`] — mirrors `macula_ucan_nif`'s -/// `ucan_opts()` map. -#[derive(Debug, Clone, Default)] -pub struct CreateOpts { - pub expires_at: Option, - pub not_before: Option, - pub nonce: Option, - pub facts: Option>, - pub proofs: Option>, -} - -/// Mints a new UCAN token, self-issued and signed by `id`. `issuer` and -/// `audience` are opaque DID strings (e.g. `"did:macula:io.macula.acme"`) -/// — this module does not validate or resolve DID structure, matching -/// `macula_ucan_nif:create/4,5`'s own scope exactly (that's -/// `macula_did_nif`'s job on the Erlang side, out of scope here). `id` -/// signs with its own Ed25519 private key; the resulting token verifies -/// against `id`'s public key ([`KeyPair::node_id`]), the same convention -/// every advertised capability in this SDK already uses. -/// -/// A token a caller presents to a procedure gated with -/// [`Policy::required`] must name that caller as its `audience`: the -/// caller's 32-byte node id as lowercase hex, with no `did:` prefix (for -/// example `hex::encode(caller.node_id())`). The provider refuses a token -/// whose `aud` names anyone other than the caller whose signature is on -/// the CALL. -pub fn create( - issuer: &str, - audience: &str, - capabilities: Vec, - id: &KeyPair, - opts: CreateOpts, -) -> Result, UcanError> { - let payload = WirePayload { - iss: issuer.to_string(), - aud: audience.to_string(), - exp: opts.expires_at, - nbf: opts.not_before, - nnc: opts.nonce, - cap: capabilities, - fct: opts.facts, - prf: opts.proofs.unwrap_or_default(), - }; - - let header_json = serde_json::to_vec(&Header { - alg: ALG.into(), - typ: TYP.into(), - ucv: UCV.into(), - }) - .map_err(|_| UcanError::InvalidToken)?; - let payload_json = serde_json::to_vec(&payload).map_err(|_| UcanError::InvalidToken)?; - let header_b64 = URL_SAFE_NO_PAD.encode(header_json); - let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json); - let signing_input = format!("{header_b64}.{payload_b64}"); - let sig = id.sign(signing_input.as_bytes()); - let sig_b64 = URL_SAFE_NO_PAD.encode(sig); - Ok(format!("{signing_input}.{sig_b64}").into_bytes()) -} - -fn split_token(token: &[u8]) -> Result<(&str, &str, &str), UcanError> { - let text = std::str::from_utf8(token).map_err(|_| UcanError::InvalidToken)?; - let mut parts = text.split('.'); - let (Some(h), Some(p), Some(s), None) = - (parts.next(), parts.next(), parts.next(), parts.next()) - else { - return Err(UcanError::InvalidToken); - }; - Ok((h, p, s)) -} - -fn decode_payload(payload_b64: &str) -> Result { - let raw = URL_SAFE_NO_PAD - .decode(payload_b64) - .map_err(|_| UcanError::InvalidToken)?; - let wp: WirePayload = serde_json::from_slice(&raw).map_err(|_| UcanError::InvalidToken)?; - Ok(Payload { - issuer: wp.iss, - audience: wp.aud, - capabilities: wp.cap, - expires_at: wp.exp, - not_before: wp.nbf, - nonce: wp.nnc.unwrap_or_default(), - facts: wp.fct, - proofs: wp.prf, - }) -} - -/// Parses a UCAN token's payload WITHOUT verifying its signature or -/// checking expiration. Mirrors `macula_ucan_nif:decode/1` — same warning -/// applies: never use this for an authorization decision, only [`verify`] -/// does that. -pub fn decode(token: &[u8]) -> Result { - let (_, payload_b64, _) = split_token(token)?; - decode_payload(payload_b64) -} - -fn now_unix() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - -/// Checks a UCAN token's signature against `public_key` (the claimed -/// issuer's 32-byte Ed25519 public key) and its `exp`/`nbf` claims against -/// the current time, returning the decoded payload only on full success. -/// Mirrors `macula_ucan_nif:verify/2` exactly, including its check ORDER — -/// public key shape, then token shape, then `exp`, then `nbf`, then -/// signature — matching both the Erlang fallback and the Rust NIF, which -/// check claims before the signature; this module preserves that order for -/// parity even though it means an invalid-but-well-formed token's expiry -/// is observable before its signature is checked. -pub fn verify(token: &[u8], public_key: &[u8; 32]) -> Result { - let (header_b64, payload_b64, sig_b64) = split_token(token)?; - let payload = decode_payload(payload_b64)?; - let now = now_unix(); - if let Some(exp) = payload.expires_at { - if now > exp { - return Err(UcanError::Expired); - } - } - if let Some(nbf) = payload.not_before { - if now < nbf { - return Err(UcanError::NotYetValid); - } - } - let sig_bytes = URL_SAFE_NO_PAD - .decode(sig_b64) - .map_err(|_| UcanError::InvalidToken)?; - let sig: [u8; 64] = sig_bytes.try_into().map_err(|_| UcanError::InvalidToken)?; - let signing_input = format!("{header_b64}.{payload_b64}"); - if !identity_verify(signing_input.as_bytes(), &sig, public_key) { - return Err(UcanError::InvalidSignature); - } - Ok(payload) -} - -/// Returns a UCAN token's content identifier: SHA-256 of the raw token -/// bytes, base64url-no-pad encoded. NOT a real multihash/CIDv1 — matches -/// `macula_ucan_nif:compute_cid/1`'s own (loosely-named) scheme exactly. -/// Used only for proof-chain references between UCANs (a child token's -/// `prf` entries name parent tokens by this value). -pub fn compute_cid(token: &[u8]) -> String { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(token); - URL_SAFE_NO_PAD.encode(hasher.finalize()) -} - -/// Decodes `token` (without verifying it) and returns its `iss` claim. -/// Mirrors `macula_ucan_nif:get_issuer/1`. -pub fn get_issuer(token: &[u8]) -> Result { - decode(token).map(|p| p.issuer) -} - -/// Decodes `token` (without verifying it) and returns its `aud` claim. -/// Mirrors `macula_ucan_nif:get_audience/1`. -pub fn get_audience(token: &[u8]) -> Result { - decode(token).map(|p| p.audience) -} - -/// Decodes `token` (without verifying it) and returns its `cap` claim. -/// Mirrors `macula_ucan_nif:get_capabilities/1`. -pub fn get_capabilities(token: &[u8]) -> Result, UcanError> { - decode(token).map(|p| p.capabilities) -} - -/// Decodes `token` (without verifying it) and returns its `exp` claim, or -/// `None` if absent. Mirrors `macula_ucan_nif:get_expiration/1`. -pub fn get_expiration(token: &[u8]) -> Result, UcanError> { - decode(token).map(|p| p.expires_at) -} - -/// Decodes `token` (without verifying it) and returns its `prf` claim. -/// Mirrors `macula_ucan_nif:get_proofs/1`. -pub fn get_proofs(token: &[u8]) -> Result, UcanError> { - decode(token).map(|p| p.proofs) -} - -/// Decodes `token` (without verifying it) and reports whether its `exp` -/// claim is in the past. A token with no `exp` claim is never expired. -/// Mirrors `macula_ucan_nif:is_expired/1`. -pub fn is_expired(token: &[u8]) -> Result { - let payload = decode(token)?; - Ok(match payload.expires_at { - Some(exp) => now_unix() > exp, - None => false, - }) -} - -/// What a provider requires to answer one `(realm, procedure)`: open (any -/// identified caller, the default) or UCAN-gated (the caller's token must -/// verify against `required_issuer`). Mirrors `macula_station_link.erl`'s -/// own policy shape exactly — `open | {ucan_required, Issuer}` — where -/// `Issuer` there is the 32-byte Ed25519 public key the gate checks the -/// token's signature against, not a DID string (the reference code passes -/// it straight to `macula_ucan_nif:verify/2`, whose second argument is a -/// raw public key). -/// -/// Gating happens BEFORE a handler runs — see -/// [`crate::connection::Session::serve_one_call_gated`] — so a rejected -/// caller never reaches business logic, and an accepted caller's handler -/// never sees the raw token either; the policy layer already did the only -/// thing that mattered with it. -/// -/// A gated policy also binds the token to the caller presenting it: the -/// token's `aud` must be that caller's 32-byte node id as lowercase hex, so -/// a token minted for someone else is refused. The serve path hands a CALL -/// to the policy only once its signature verifies against the `caller` it -/// names. -#[derive(Debug, Clone, Default)] -pub struct Policy { - pub gated: bool, - pub required_issuer: [u8; 32], -} - -impl Policy { - /// The default, ungated policy: any identified caller may invoke the - /// procedure, no UCAN token needed. Equivalent to Erlang's `open`. - pub fn open() -> Self { - Self::default() - } - - /// Builds a UCAN-gated policy: a caller must present a token that - /// verifies (signature, `exp`, `nbf`) against `issuer_public_key` and - /// names that caller as its audience. Equivalent to Erlang's - /// `{ucan_required, issuer_public_key}`. - pub fn required(issuer_public_key: [u8; 32]) -> Self { - Self { - gated: true, - required_issuer: issuer_public_key, - } - } - - /// Applies this policy to an inbound CALL's `ucan_token` and `caller`, - /// returning `Ok(())` if the call is authorized to proceed to - /// lookup/dispatch. An open policy always passes. A gated policy - /// requires a 32-byte `caller`, requires `ucan_token` to [`verify`] - /// against `required_issuer`, and requires the token's `aud` to equal - /// `caller` as lowercase hex, the same comparison - /// `macula_station_link.erl` makes. - pub fn check(&self, ucan_token: &[u8], caller: &[u8]) -> Result<(), UcanError> { - if !self.gated { - return Ok(()); - } - if ucan_token.is_empty() { - return Err(UcanError::NoToken); - } - if caller.len() != 32 { - return Err(UcanError::NoCaller); - } - let payload = verify(ucan_token, &self.required_issuer)?; - if payload.audience != lowercase_hex(caller) { - return Err(UcanError::WrongAudience); - } - Ok(()) - } -} - -/// `bytes` as lowercase hex, the form a token's `aud` names its caller in. -fn lowercase_hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn keypair() -> KeyPair { - KeyPair::generate() - } - - #[test] - fn create_and_verify_round_trip() { - let id = keypair(); - let token = create( - "did:macula:issuer", - "did:macula:audience", - vec![Capability { - with: "mri:x".into(), - can: "read".into(), - }], - &id, - CreateOpts::default(), - ) - .unwrap(); - let payload = verify(&token, &id.node_id()).unwrap(); - assert_eq!(payload.issuer, "did:macula:issuer"); - assert_eq!(payload.audience, "did:macula:audience"); - assert_eq!( - payload.capabilities, - vec![Capability { - with: "mri:x".into(), - can: "read".into() - }] - ); - } - - #[test] - fn verify_rejects_tampered_payload() { - let id = keypair(); - let token = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap(); - let mut text = String::from_utf8(token).unwrap(); - // Flip a byte in the payload segment without corrupting base64 - // framing -- this is the same tamper strategy this crate's other - // signed-record tests already use (see dht.rs's tests). - let parts: Vec<&str> = text.split('.').collect(); - let mut payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); - payload_bytes[0] ^= 0xFF; - let tampered_payload = URL_SAFE_NO_PAD.encode(payload_bytes); - text = format!("{}.{}.{}", parts[0], tampered_payload, parts[2]); - let err = verify(text.as_bytes(), &id.node_id()).unwrap_err(); - assert!(matches!( - err, - UcanError::InvalidToken | UcanError::InvalidSignature - )); - } - - #[test] - fn verify_rejects_wrong_signer() { - let id = keypair(); - let other = keypair(); - let token = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap(); - let err = verify(&token, &other.node_id()).unwrap_err(); - assert!(matches!(err, UcanError::InvalidSignature)); - } - - #[test] - fn verify_rejects_expired() { - let id = keypair(); - let opts = CreateOpts { - expires_at: Some(now_unix() - 60), - ..Default::default() - }; - let token = create("iss", "aud", vec![], &id, opts).unwrap(); - let err = verify(&token, &id.node_id()).unwrap_err(); - assert!(matches!(err, UcanError::Expired)); - } - - #[test] - fn verify_rejects_not_yet_valid() { - let id = keypair(); - let opts = CreateOpts { - not_before: Some(now_unix() + 3600), - ..Default::default() - }; - let token = create("iss", "aud", vec![], &id, opts).unwrap(); - let err = verify(&token, &id.node_id()).unwrap_err(); - assert!(matches!(err, UcanError::NotYetValid)); - } - - #[test] - fn decode_does_not_check_signature() { - let id = keypair(); - let other = keypair(); - let token = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap(); - // decode() against ANY key (or none at all) still returns the - // payload -- it never checks the signature, matching - // macula_ucan_nif:decode/1's own documented warning. - let payload = decode(&token).unwrap(); - assert_eq!(payload.issuer, "iss"); - let _ = other; // not used for verification here, on purpose - } - - #[test] - fn getters_match_created_claims() { - let id = keypair(); - let caps = vec![Capability { - with: "mri:x".into(), - can: "write".into(), - }]; - let opts = CreateOpts { - expires_at: Some(now_unix() + 3600), - proofs: Some(vec!["parent-cid".into()]), - ..Default::default() - }; - let token = create("did:iss", "did:aud", caps.clone(), &id, opts).unwrap(); - assert_eq!(get_issuer(&token).unwrap(), "did:iss"); - assert_eq!(get_audience(&token).unwrap(), "did:aud"); - assert_eq!(get_capabilities(&token).unwrap(), caps); - assert!(get_expiration(&token).unwrap().is_some()); - assert_eq!(get_proofs(&token).unwrap(), vec!["parent-cid".to_string()]); - assert!(!is_expired(&token).unwrap()); - } - - #[test] - fn is_expired_true_for_past_exp() { - let id = keypair(); - let opts = CreateOpts { - expires_at: Some(now_unix() - 1), - ..Default::default() - }; - let token = create("iss", "aud", vec![], &id, opts).unwrap(); - // is_expired() never checks the signature either -- consistent - // with every other getter in this module. - assert!(is_expired(&token).unwrap()); - } - - #[test] - fn is_expired_false_with_no_exp_claim() { - let id = keypair(); - let token = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap(); - assert!(!is_expired(&token).unwrap()); - } - - #[test] - fn cid_is_deterministic_and_content_addressed() { - let id = keypair(); - let token_a = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap(); - assert_eq!(compute_cid(&token_a), compute_cid(&token_a)); - let token_b = create("iss2", "aud", vec![], &id, CreateOpts::default()).unwrap(); - assert_ne!(compute_cid(&token_a), compute_cid(&token_b)); - } - - #[test] - fn policy_open_never_requires_a_token() { - let policy = Policy::open(); - assert!(policy.check(&[], &[]).is_ok()); - } - - #[test] - fn policy_required_rejects_empty_token() { - let id = keypair(); - let caller = keypair(); - let policy = Policy::required(id.node_id()); - assert!(matches!( - policy.check(&[], &caller.node_id()).unwrap_err(), - UcanError::NoToken - )); - } - - #[test] - fn policy_required_accepts_valid_token_from_the_right_issuer() { - let id = keypair(); - let caller = keypair(); - let token = create( - "did:iss", - &hex::encode(caller.node_id()), - vec![], - &id, - CreateOpts::default(), - ) - .unwrap(); - let policy = Policy::required(id.node_id()); - assert!(policy.check(&token, &caller.node_id()).is_ok()); - } - - #[test] - fn policy_required_rejects_token_from_the_wrong_issuer() { - let id = keypair(); - let impostor = keypair(); - let caller = keypair(); - let token = create( - "did:iss", - &hex::encode(caller.node_id()), - vec![], - &impostor, - CreateOpts::default(), - ) - .unwrap(); - let policy = Policy::required(id.node_id()); - assert!(matches!( - policy.check(&token, &caller.node_id()).unwrap_err(), - UcanError::InvalidSignature - )); - } - - #[test] - fn policy_required_refuses_a_token_for_another_audience() { - let (issuer, audience, presenter) = (keypair(), keypair(), keypair()); - let token = create( - "did:iss", - &hex::encode(audience.node_id()), - vec![], - &issuer, - CreateOpts::default(), - ) - .unwrap(); - let policy = Policy::required(issuer.node_id()); - - assert!(matches!( - policy.check(&token, &presenter.node_id()).unwrap_err(), - UcanError::WrongAudience - )); - // The same token from its audience passes. - assert!(policy.check(&token, &audience.node_id()).is_ok()); - } - - #[test] - fn policy_required_refuses_a_missing_or_malformed_audience_or_caller() { - let (issuer, caller) = (keypair(), keypair()); - let policy = Policy::required(issuer.node_id()); - let caller_hex = hex::encode(caller.node_id()); - let token_for = |audience: &str| { - create("did:iss", audience, vec![], &issuer, CreateOpts::default()).unwrap() - }; - - for audience in [ - String::new(), - caller_hex.to_uppercase(), - format!("did:macula:{caller_hex}"), - ] { - assert!( - matches!( - policy.check(&token_for(&audience), &caller.node_id()), - Err(UcanError::WrongAudience) - ), - "audience {audience:?} must be refused" - ); - } - assert!(matches!( - policy.check(&token_for(&caller_hex), &[]), - Err(UcanError::NoCaller) - )); - } -} diff --git a/tests/identity_binding.rs b/tests/identity_binding.rs index 446c442..d3d8ce6 100644 --- a/tests/identity_binding.rs +++ b/tests/identity_binding.rs @@ -12,6 +12,9 @@ use macula_rust::cbor; use macula_rust::node_key::{node_id_of, NodeKey, Purpose}; use macula_rust::profile::Profile; +/// A change to a signed structure that must leave it unverifiable. +type Alteration = Box SignedTbs>; + const DAY_MS: i64 = 24 * 60 * 60 * 1000; const MINUTE_MS: i64 = 60 * 1000; const MLDSA_SIGNATURE: usize = 4627; @@ -173,11 +176,10 @@ fn a_binding_or_statement_macula_made_altered_by_one_byte_is_refused() { if p == Profile::PqHybrid { offsets.push(MLDSA_SIGNATURE + 10); } - let mut alterations: Vec SignedTbs>> = - vec![Box::new(|s: &SignedTbs| SignedTbs { - tbs: flipped(&s.tbs, s.tbs.len() / 2), - signature: s.signature.clone(), - })]; + let mut alterations: Vec = vec![Box::new(|s: &SignedTbs| SignedTbs { + tbs: flipped(&s.tbs, s.tbs.len() / 2), + signature: s.signature.clone(), + })]; for offset in offsets { alterations.push(Box::new(move |s: &SignedTbs| SignedTbs { tbs: s.tbs.clone(), diff --git a/tests/identity_key_file.rs b/tests/identity_key_file.rs index 7ba4351..d5281d0 100644 --- a/tests/identity_key_file.rs +++ b/tests/identity_key_file.rs @@ -192,3 +192,46 @@ fn a_directory_or_an_oversized_file_is_refused() { Err(KeyFileError::TooLarge) )); } + +/// A key store of the caller's own, which the trait lets any backend be. +struct InMemory(std::sync::Mutex>>); + +impl macula_rust::keystore::KeyStore for InMemory { + fn save_key(&self, key: &[u8]) -> Result<(), macula_rust::keystore::KeyStoreError> { + *self.0.lock().unwrap() = Some(key.to_vec()); + Ok(()) + } + fn load_key( + &self, + ) -> Result>, macula_rust::keystore::KeyStoreError> { + self.0 + .lock() + .unwrap() + .clone() + .map(macula_mldsa::Zeroizing::new) + .ok_or(macula_rust::keystore::KeyStoreError::NotFound) + } + fn delete_key(&self) -> Result<(), macula_rust::keystore::KeyStoreError> { + *self.0.lock().unwrap() = None; + Ok(()) + } +} + +#[test] +fn a_key_kept_in_a_key_store_loads_back_as_the_same_key_and_is_checked_as_a_file_is() { + let store = InMemory(std::sync::Mutex::new(None)); + assert!(matches!( + NodeKey::load_from_keystore(&store, Purpose::Identity, Profile::PqHybrid), + Err(KeyFileError::KeyStore( + macula_rust::keystore::KeyStoreError::NotFound + )) + )); + let key = NodeKey::generate(Purpose::Identity, Profile::PqHybrid).unwrap(); + key.save_to_keystore(&store).unwrap(); + let loaded = NodeKey::load_from_keystore(&store, Purpose::Identity, Profile::PqHybrid).unwrap(); + assert_eq!(loaded.public_key(), key.public_key()); + assert!(matches!( + NodeKey::load_from_keystore(&store, Purpose::Identity, Profile::PqPure), + Err(KeyFileError::WrongProfile(Profile::PqHybrid)) + )); +} diff --git a/tests/live_cert_chain.rs b/tests/live_cert_chain.rs deleted file mode 100644 index 3fa39bc..0000000 --- a/tests/live_cert_chain.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! Live proof that a `cert_chain`-bearing `procedure_advertisement` survives -//! a REAL DHT publish/resolve round trip and still verifies correctly -//! afterward — the offline unit tests in `src/cert_chain.rs` never touch -//! the network, so they can't catch a wire-encoding bug (e.g. the -//! `cert_chain` bytes getting mangled in transit) the way this can. -//! -//! No fleet provisioning needed: the realm CA/leaf chain is entirely -//! self-issued by this test, since cert-chain authorization is a -//! client-side check on an opaque DHT payload the station itself never -//! inspects (mirrors `macula-go`'s `TestLiveResolveWithCertChain`, -//! which makes the same observation). -//! -//! Not run by default CI — `#[ignore]`d, matching this crate's other live -//! tests (`tests/live_station.rs`). Run explicitly with -//! `cargo test --test live_cert_chain -- --ignored`. - -use std::time::Duration; - -use macula_rust::cert_chain::{verify_advertisement_cert_chain, CertChainError}; -use macula_rust::connection; -use macula_rust::direct_dial; -use macula_rust::identity::KeyPair; -use macula_rust::transport::Trust; -use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair as RcgenKeyPair}; - -const STATION_HOST: &str = "station-de-frankfurt.macula.io"; -const STATION_PORT: u16 = 4433; - -fn self_issued_realm_ca() -> (Vec, rcgen::Issuer<'static, RcgenKeyPair>) { - let key_pair = RcgenKeyPair::generate_for(&rcgen::PKCS_ED25519).expect("ca keygen"); - let mut params = CertificateParams::new(Vec::::new()).expect("ca params"); - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "Live Test Realm CA"); - dn.push(DnType::OrganizationName, "Live Test Realm CA"); - params.distinguished_name = dn; - params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); - params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1); - params.not_after = time::OffsetDateTime::now_utc() + time::Duration::hours(1); - let cert = params.self_signed(&key_pair).expect("ca self-sign"); - let pem = cert.pem().into_bytes(); - (pem, rcgen::Issuer::new(params, key_pair)) -} - -/// RFC 8410 SubjectPublicKeyInfo DER for a raw 32-byte Ed25519 pubkey — -/// duplicated from `src/cert_chain.rs`'s own `#[cfg(test)]` helper since an -/// integration test in `tests/` can't reach items private to the lib's -/// test module. -fn ed25519_spki_der(pubkey: [u8; 32]) -> Vec { - let mut der = vec![ - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, - ]; - der.extend_from_slice(&pubkey); - der -} - -fn issue_leaf( - ca_issuer: &rcgen::Issuer<'static, RcgenKeyPair>, - advertiser_pub: [u8; 32], - org: &str, -) -> Vec { - let subject_spki = - rcgen::SubjectPublicKeyInfo::from_der(&ed25519_spki_der(advertiser_pub)).expect("spki"); - let mut params = CertificateParams::new(Vec::::new()).expect("leaf params"); - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "live-cert-chain-test-service"); - dn.push(DnType::OrganizationName, org); - params.distinguished_name = dn; - params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1); - params.not_after = time::OffsetDateTime::now_utc() + time::Duration::hours(1); - let cert = params - .signed_by(&subject_spki, ca_issuer) - .expect("leaf signed_by"); - cert.der().to_vec() -} - -fn pem_bundle(ders: &[Vec]) -> Vec { - use base64::Engine; - let mut out = Vec::new(); - for der in ders { - let b64 = base64::engine::general_purpose::STANDARD.encode(der); - out.extend_from_slice(b"-----BEGIN CERTIFICATE-----\n"); - for chunk in b64.as_bytes().chunks(64) { - out.extend_from_slice(chunk); - out.push(b'\n'); - } - out.extend_from_slice(b"-----END CERTIFICATE-----\n"); - } - out -} - -/// Publishes a `cert_chain`-bearing advertisement for real, resolves it -/// back over a SEPARATE session/identity, and confirms the resolved -/// record's embedded chain still verifies -- proving the wire round trip -/// (CBOR-encode the PEM bytes into a DHT record, publish via `_dht.put_record`, -/// read it back via `_dht.find_records`) doesn't corrupt the chain. Also -/// checks the negative control: the SAME resolved record correctly fails -/// authorization for the WRONG org. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn cert_chain_survives_a_real_dht_round_trip() { - let (ca_pem, ca_issuer) = self_issued_realm_ca(); - - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - let leaf_der = issue_leaf(&ca_issuer, provider_identity.node_id(), "acme-corp"); - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake should succeed"); - let resolver_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_identity) - .await - .expect("resolver handshake should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.live_cert_chain_test.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - direct_dial::advertise_direct_with_cert_chain( - &provider_session, - &provider_identity, - realm, - &procedure, - Duration::from_secs(120), - pem_bundle(&[leaf_der]), - ) - .await - .expect("advertise_direct_with_cert_chain should publish the DHT record"); - - let resolved = direct_dial::resolve_with_cert_chain( - &resolver_session, - &caller_identity, - realm, - &procedure, - &ca_pem, - "acme-corp", - ) - .await - .expect("resolve_with_cert_chain should find and authorize what was just published"); - assert_eq!( - resolved.station, provider_session.station.node_id, - "resolved station should be the provider's own" - ); - - // Negative control on the SAME real, network-round-tripped record. - let err = direct_dial::resolve_with_cert_chain( - &resolver_session, - &caller_identity, - realm, - &procedure, - &ca_pem, - "wrong-org", - ) - .await - .expect_err("a real cert chain issued for acme-corp must not authorize wrong-org"); - match err { - direct_dial::ResolveError::NoAuthorizedAdvertisement(CertChainError::OrgMismatch) => {} - other => panic!("expected NoAuthorizedAdvertisement(OrgMismatch), got {other:?}"), - } - - // Also confirm the record's chain still verifies directly, byte for - // byte, via the resolved path -- redundant with resolve_with_cert_chain - // succeeding above, but pins down that verify_advertisement_cert_chain - // itself (not just the resolve wrapper) is what's being exercised. - let recs = macula_rust::dht::find_records( - &resolver_session, - &caller_identity, - macula_rust::dht::procedure_key(&macula_rust::dht::discovery_uri(realm, &procedure)), - ) - .await - .expect("find_records should return the published record"); - assert!( - recs.iter() - .any(|r| verify_advertisement_cert_chain(&ca_pem, r, "acme-corp").is_ok()), - "at least one resolved record must verify byte-for-byte after the real DHT round trip" - ); - - provider_session - .close("normal", None, &provider_identity) - .await; - resolver_session - .close("normal", None, &caller_identity) - .await; -} diff --git a/tests/live_direct_dial_extensions.rs b/tests/live_direct_dial_extensions.rs deleted file mode 100644 index 81c6408..0000000 --- a/tests/live_direct_dial_extensions.rs +++ /dev/null @@ -1,264 +0,0 @@ -//! Live proof that direct-dial's resolve-and-dial core, already verified -//! for plain RPC (`tests/live_station.rs`) and cert-chain authorization -//! (`tests/live_cert_chain.rs`), reuses cleanly for streaming and content -//! transfer too — mirrors `macula-go`'s own `OpenStreamDirect`/ -//! `PutDirect`/`GetDirect` live tests. -//! -//! Separate identities per role throughout: this fleet enforces one -//! connection per identity and kicks whichever connects second (confirmed -//! multiple times this session), so a provider/caller/resolver sharing one -//! identity self-inflicts a kick rather than testing anything real. -//! -//! Not run by default CI — `#[ignore]`d, matching this crate's other live -//! tests. Run explicitly with -//! `cargo test --test live_direct_dial_extensions -- --ignored --nocapture`. - -use std::time::Duration; - -use macula_rust::cbor::Value; -use macula_rust::connection; -use macula_rust::direct_dial; -use macula_rust::frame::StreamMode; -use macula_rust::identity::KeyPair; -use macula_rust::transport::Trust; - -const STATION_HOST: &str = "station-fi-helsinki.macula.io"; -const STATION_PORT: u16 = 4433; - -fn now_ms() -> i128 { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock before 1970") - .as_millis() as i128 -} - -/// Advertise+serve a stream via direct-dial in one task, resolve+dial+open -/// it from a separate session/identity, push real data, confirm it -/// arrives byte-exact. Proves `open_stream_direct` genuinely reaches a -/// live provider through the DHT, not just that resolve+dial completes. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn open_stream_direct_round_trip_against_the_real_fleet() { - let provider_id = KeyPair::generate_with_default_puzzle(); - let resolver_id = KeyPair::generate_with_default_puzzle(); - let caller_id = KeyPair::generate_with_default_puzzle(); - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "live_direct_dial_extensions.stream.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let provider_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &provider_id) - .await - .expect("provider handshake should succeed"); - - direct_dial::advertise_direct( - &provider_session, - &provider_id, - realm, - &procedure, - Duration::from_secs(3600), - ) - .await - .expect("advertise_direct should publish both the plain ADVERTISE and the DHT record"); - - // Only accept() happens inside the spawned task, exactly matching - // streaming_provider_round_trip_against_the_real_fleet's - // (tests/live_station.rs) already-proven structure -- send_data/ - // close_send happen afterward in the main task, and provider_session - // is kept alive (never let drop implicitly) until an explicit - // graceful close at the very end. An earlier draft did send_data/ - // close_send INSIDE the spawned task and let provider_session drop - // at the task's end -- real bug, reproduced live: the caller saw - // `Recv(StreamClosed)` instead of the pushed data, because the - // implicit drop tore the connection down before the already-sent - // frame had necessarily been fully processed peer-side. - let accept_task = tokio::spawn(async move { - let result = - macula_rust::stream::StreamHandle::accept(&provider_session, Duration::from_secs(15)) - .await; - (result, provider_session) - }); - - let resolver_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &resolver_id) - .await - .expect("resolver handshake should succeed"); - - let opened = match direct_dial::open_stream_direct( - &resolver_session, - &caller_id, - realm, - &procedure, - StreamMode::ServerStream, - Value::Null, - now_ms() + 15_000, - Duration::from_secs(15), - ) - .await - { - Ok(v) => v, - Err(direct_dial::OpenStreamDirectError::Resolve( - direct_dial::ResolveError::StationEndpointNotFound, - )) => { - eprintln!( - "SKIP: resolved station published no reachable station_endpoint -- known external fleet staleness, not a defect here" - ); - return; - } - Err(e) => panic!("open_stream_direct should resolve, dial, and open: {e}"), - }; - let mut handle = opened.stream; - let lease = opened.lease; - - let (accept_result, provider_session) = - accept_task.await.expect("accept task should not panic"); - let (mut provider_handle, open_info) = accept_result - .expect("provider should accept the inbound STREAM_OPEN routed via the plain ADVERTISE"); - assert_eq!(open_info.procedure, procedure); - - provider_handle - .send_data( - macula_rust::frame::StreamEncoding::Raw, - Value::Bytes(b"hello via direct-dial stream".to_vec()), - &provider_id, - ) - .await - .expect("provider should push the chunk"); - provider_handle - .close_send(&provider_id) - .await - .expect("provider should half-close"); - - match handle - .recv(Duration::from_secs(10)) - .await - .expect("caller should receive the pushed chunk") - { - macula_rust::stream::StreamItem::Data { - body: Value::Bytes(got), - .. - } => { - assert_eq!(got, b"hello via direct-dial stream"); - println!( - "OBSERVED: real data received through a direct-dial-opened stream: {} bytes", - got.len() - ); - } - other => panic!("expected a real data chunk through direct-dial, got: {other:?}"), - } - match handle - .recv(Duration::from_secs(5)) - .await - .expect("caller should see end-of-stream") - { - macula_rust::stream::StreamItem::Eof => {} - other => panic!("expected Eof, got {other:?}"), - } - - provider_session - .close("normal", Some("provider test done"), &provider_id) - .await; - lease.release(&caller_id).await; -} - -/// Put content at a known station via direct-dial, then fetch it back -/// through an independent `content_announcement` published for it, -/// confirming a byte-exact round trip entirely through direct-dial-resolved -/// connections. `get_direct` needs a real announcement to resolve, so this -/// test builds one itself with `dht::new_content_announcement` (the -/// low-level primitive this crate deliberately does NOT expose as a -/// client-facing "announce content direct" — see `get_direct`'s own doc -/// for why only an infrastructure identity can legitimately publish one) -/// naming the SAME station `put_direct` just stored the content on, which -/// is honest here: the test plays the infrastructure role for its own -/// fixture, an ordinary leaf would not do this for itself. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn put_and_get_direct_round_trip_against_the_real_fleet() { - let resolver_id = KeyPair::generate_with_default_puzzle(); - let putter_id = KeyPair::generate_with_default_puzzle(); - let announcer_id = KeyPair::generate_with_default_puzzle(); - let getter_id = KeyPair::generate_with_default_puzzle(); - - let resolver_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &resolver_id) - .await - .expect("resolver handshake should succeed"); - let station = resolver_session.station.node_id; - - let data = b"real bytes stored and fetched purely via direct-dial".to_vec(); - let mcid = match direct_dial::put_direct( - &resolver_session, - &putter_id, - station, - &data, - "live-direct-dial-extensions-test", - Duration::from_secs(15), - ) - .await - { - Ok(mcid) => mcid, - Err(direct_dial::PutDirectError::Resolve( - direct_dial::ResolveError::StationEndpointNotFound, - )) => { - eprintln!("SKIP: station published no reachable station_endpoint -- known external fleet staleness"); - return; - } - Err(e) => panic!("put_direct should resolve, dial, and store: {e}"), - }; - println!( - "OBSERVED: put_direct stored {} bytes, mcid={}", - data.len(), - hex::encode(mcid) - ); - - // Publish the content_announcement ourselves, playing the - // infrastructure role this crate's own leaf API deliberately can't -- - // see get_direct's doc. - let announcer_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &announcer_id) - .await - .expect("announcer handshake should succeed"); - let endpoint = format!("https://{STATION_HOST}:{STATION_PORT}"); - let rec = macula_rust::dht::new_content_announcement( - announcer_id.node_id(), - mcid, - endpoint, - Duration::from_secs(3600), - ); - let rec = macula_rust::dht::sign(rec, &announcer_id); - macula_rust::dht::put_record(&announcer_session, &announcer_id, &rec) - .await - .expect("publishing the content_announcement should succeed"); - - // The announced endpoint (this SAME station, in this test's fixture) - // must actually answer as the identity the announcement claims for - // get_direct's trust check to pass -- announce the announcer's own - // session as reachable there isn't meaningful (content is served by - // the STATION, not by announcer_session), so this test can only prove - // get_direct correctly REFUSES an announcement whose claimed announcer - // doesn't match who answers the dial, which is itself a real - // correctness property worth confirming. - let getter_session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &getter_id) - .await - .expect("getter handshake should succeed"); - match direct_dial::get_direct(&getter_session, &getter_id, mcid, Duration::from_secs(15)).await { - Err(direct_dial::GetDirectError::Dial(direct_dial::DialAndVerifyError::TrustViolation { resolved, dialed })) => { - println!( - "OBSERVED: get_direct correctly refused a content_announcement whose claimed announcer ({}) doesn't match the station that actually answers the dial ({}) -- confirms the trust check fires, matching how put_direct's own data landed on the real station instead", - hex::encode(resolved), hex::encode(dialed) - ); - } - Err(e) => panic!("expected a trust-violation refusal (this fixture announces an identity that can't answer the dial), got: {e}"), - Ok(got) => { - // If this ever succeeds for real (e.g. the fixture's announcer - // identity happens to equal the station), it must still be - // byte-exact. - assert_eq!(got, data); - println!("OBSERVED: get_direct fetched a byte-exact round trip through direct-dial"); - } - } -} diff --git a/tests/live_direct_dial_ucan.rs b/tests/live_direct_dial_ucan.rs deleted file mode 100644 index 1aca550..0000000 --- a/tests/live_direct_dial_ucan.rs +++ /dev/null @@ -1,256 +0,0 @@ -//! Proves `direct_dial::call_with_ucan` actually reaches a UCAN-gated -//! procedure that plain `direct_dial::call` cannot -- the gap this -//! function closes (PLAN_CLOSE_SERVICE_AUTH_GAPS.md Phase 0, -//! macula-io/macula-architecture): every hecate-om capability is -//! advertised via `advertise_direct`, and until this function existed, -//! nothing in this crate could attach a token to a direct-dial call at -//! all -- a `ucan_required` capability was reachable in name only. Three -//! assertions against the live fleet: an unauthorized plain `call` is -//! refused, a `call_with_ucan` presenting a token from the WRONG issuer is -//! refused too (not just "any non-empty token passes"), and a -//! correctly-issued token gets a real result. -//! -//! Not run by default CI -- `#[ignore]`d, matching this crate's other live -//! tests. Run explicitly with -//! `cargo test --test live_direct_dial_ucan -- --ignored --nocapture`. -//! -//! MUST use `flavor = "multi_thread"` -- found live building this test: a -//! provider `Session` moved into a spawned task blocking inside -//! `serve_one_call_gated` starves a CONCURRENT resolver session's own DHT -//! resolution on tokio's default single-threaded (current_thread) test -//! runtime, failing with `StationEndpointNotFound` even though the record -//! is real and freshly published (confirmed by isolating it: the identical -//! resolve succeeds instantly with no concurrent task, and with a -//! concurrent task that does nothing network-related; it only breaks once -//! a spawned task owns and blocks a `Session`). Not fleet flakiness -- -//! reproduced identically against two different stations, while an -//! unrelated pre-existing test passed cleanly against both at the same -//! moment. This crate's other live tests never spawn a task holding a -//! `Session` alongside other concurrent network I/O, so this is the first -//! to hit it. - -use std::time::Duration; - -use macula_rust::cbor::Value; -use macula_rust::connection::{self, CallHandler}; -use macula_rust::direct_dial; -use macula_rust::frame::CallResponse; -use macula_rust::identity::KeyPair; -use macula_rust::transport::Trust; -use macula_rust::ucan; - -const STATION_HOST: &str = "station-de-frankfurt.macula.io"; -const STATION_PORT: u16 = 4433; - -#[tokio::test(flavor = "multi_thread")] -#[ignore = "requires network access to a live macula-station"] -async fn ucan_gated_capability_reachable_only_through_call_with_ucan() { - // Arc'd: KeyPair isn't Clone, and the provider identity is needed - // inside 3 separate spawned serve tasks below. - let provider_id = std::sync::Arc::new(KeyPair::generate_with_default_puzzle()); - let caller_id = KeyPair::generate_with_default_puzzle(); - let issuer_id = KeyPair::generate_with_default_puzzle(); - let wrong_issuer_id = KeyPair::generate_with_default_puzzle(); - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "live_direct_dial_ucan.gated.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let valid_token = ucan::create( - &hex::encode(issuer_id.node_id()), - &hex::encode(caller_id.node_id()), - vec![ucan::Capability { - with: "mri:test:live".into(), - can: "call".into(), - }], - &issuer_id, - ucan::CreateOpts::default(), - ) - .expect("mint valid token"); - let wrong_issuer_token = ucan::create( - &hex::encode(wrong_issuer_id.node_id()), - &hex::encode(caller_id.node_id()), - vec![ucan::Capability { - with: "mri:test:live".into(), - can: "call".into(), - }], - &wrong_issuer_id, - ucan::CreateOpts::default(), - ) - .expect("mint wrong-issuer token"); - - let provider = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &provider_id) - .await - .expect("provider handshake should succeed"); - direct_dial::advertise_direct( - &provider, - &provider_id, - realm, - &procedure, - Duration::from_secs(3600), - ) - .await - .expect("advertise_direct should succeed"); - - let required_policy = ucan::Policy::required(issuer_id.node_id()); - let echo: CallHandler = std::sync::Arc::new(|payload: Value| { - Box::pin(async move { Ok(Value::Map(vec![]).with_field("echo", payload)) }) - }); - let lookup = { - let procedure = procedure.clone(); - let echo = echo.clone(); - move |_realm: &[u8; 32], proc: &str| { - if proc == procedure { - Some(echo.clone()) - } else { - None - } - } - }; - let policy = { - let procedure = procedure.clone(); - move |_realm: &[u8; 32], proc: &str| { - if proc == procedure { - required_policy.clone() - } else { - ucan::Policy::open() - } - } - }; - - // serve_one_call_gated blocks waiting for an inbound call, so it must - // run CONCURRENTLY with the caller's own connect+call below, not - // before it -- spawned as a task, handing `provider` back out (and - // in again for the next round) via the JoinHandle, matching Go's - // goroutine+channel `serve()` helper in the equivalent live test. - // - // The 300ms sleep after every round matches examples/ucan.rs's own - // documented reason: `Session` has no `Drop` impl, so returning - // (and dropping `provider`, here via the task's own scope on every - // round including the reassignments below) immediately after a - // reply is sent can tear down the QUIC connection before that reply - // frame actually flushes to the peer. Found live while building this - // test: an ungated `serve_one_call`/plain `call` round-trip 3x with - // no delay was fine, but a GATED round's own RESULT reply (not its - // rejection replies, which apparently take a different, already- - // flushed path) was silently lost without this -- narrowed to - // exactly this race by direct experiment, not assumed. - let provider_id2 = provider_id.clone(); - let lookup1 = lookup.clone(); - let policy1 = policy.clone(); - let serve1 = tokio::spawn(async move { - let r = provider - .serve_one_call_gated(lookup1, policy1, &provider_id2, Duration::from_secs(15)) - .await; - tokio::time::sleep(Duration::from_millis(300)).await; - r.map(|_| provider) - }); - - // 1. Unauthorized: plain `call` cannot even attach a token. - let resolver1 = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_id) - .await - .expect("caller handshake #1 should succeed"); - let resp = direct_dial::call( - &resolver1, - &caller_id, - realm, - &procedure, - Value::text("no token"), - Duration::from_secs(12), - ) - .await - .expect("plain call should get a BOLT#4 response, not a transport error"); - match resp { - CallResponse::Error { .. } => {} - CallResponse::Result { .. } => { - panic!("plain call against a gated procedure unexpectedly SUCCEEDED") - } - } - println!("OBSERVED: plain call against a gated procedure was refused, as expected"); - let provider = serve1 - .await - .expect("serve task #1 should not panic") - .expect("serve_one_call_gated (unauthorized tick) should not error"); - - // 2. Wrong issuer. - let provider_id2 = provider_id.clone(); - let lookup2 = lookup.clone(); - let policy2 = policy.clone(); - let serve2 = tokio::spawn(async move { - let r = provider - .serve_one_call_gated(lookup2, policy2, &provider_id2, Duration::from_secs(15)) - .await; - tokio::time::sleep(Duration::from_millis(300)).await; - r.map(|_| provider) - }); - let resolver2 = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_id) - .await - .expect("caller handshake #2 should succeed"); - let resp = direct_dial::call_with_ucan( - &resolver2, - &caller_id, - realm, - &procedure, - Value::text("wrong issuer"), - Duration::from_secs(12), - wrong_issuer_token, - ) - .await - .expect("call_with_ucan (wrong issuer) should get a BOLT#4 response, not a transport error"); - match resp { - CallResponse::Error { .. } => {} - CallResponse::Result { .. } => { - panic!("call_with_ucan with a wrong-issuer token unexpectedly SUCCEEDED") - } - } - println!( - "OBSERVED: call_with_ucan with a token from the wrong issuer was refused, as expected" - ); - let provider = serve2 - .await - .expect("serve task #2 should not panic") - .expect("serve_one_call_gated (wrong-issuer tick) should not error"); - - // 3. Authorized: the actual fix under test. - let provider_id2 = provider_id.clone(); - let serve3 = tokio::spawn(async move { - let r = provider - .serve_one_call_gated(lookup, policy, &provider_id2, Duration::from_secs(15)) - .await; - tokio::time::sleep(Duration::from_millis(300)).await; - r - }); - let resolver3 = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_id) - .await - .expect("caller handshake #3 should succeed"); - let call_fut = direct_dial::call_with_ucan( - &resolver3, - &caller_id, - realm, - &procedure, - Value::text("hello gated direct-dial"), - Duration::from_secs(12), - valid_token, - ); - let (call_result, serve_result) = tokio::join!(call_fut, serve3); - let resp = call_result - .expect("call_with_ucan (authorized) should succeed -- this is the fix under test"); - match resp { - CallResponse::Result { payload, .. } => { - let echoed = payload - .get("echo") - .expect("reply payload missing echo field"); - assert_eq!(*echoed, Value::text("hello gated direct-dial")); - } - CallResponse::Error { code, name, .. } => { - panic!("call_with_ucan (authorized) returned a BOLT#4 ERROR instead of a result: code={code} name={name}") - } - } - println!( - "OBSERVED: a UCAN-gated capability, advertised only via advertise_direct, was reached and answered through call_with_ucan end to end" - ); - serve_result - .expect("serve task #3 should not panic") - .expect("serve_one_call_gated (authorized tick) should not error"); -} diff --git a/tests/live_pool.rs b/tests/live_pool.rs deleted file mode 100644 index 9c71ba9..0000000 --- a/tests/live_pool.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! Integration tests for `pool::Pool` against real, live macula-station -//! boxes. -//! -//! **Not run by default CI** — every test here is `#[ignore]`d, matching -//! `tests/live_station.rs`'s own convention. Run explicitly with: -//! -//! ```text -//! cargo test --test live_pool -- --ignored --nocapture -//! ``` - -use std::time::Duration; - -use macula_rust::cbor::Value; -use macula_rust::frame::CallResponse; -use macula_rust::identity::KeyPair; -use macula_rust::pool::{ - LinkSelection, Pool, PoolOptions, PoolStatus, Seed, StationDiscoveryOptions, -}; -use macula_rust::transport::Trust; - -const STATION_HOST: &str = "station-de-frankfurt.macula.io"; -const STATION_PORT: u16 = 4433; - -/// Polls `pool.status()` until `until` returns true or `timeout` elapses. -/// Returns the last observed [`PoolStatus`] either way, so a caller can -/// build a rich panic message from it on failure. -async fn wait_for_status( - pool: &Pool, - until: impl Fn(&PoolStatus) -> bool, - timeout: Duration, -) -> PoolStatus { - let deadline = std::time::Instant::now() + timeout; - loop { - let status = pool.status().await; - if until(&status) || std::time::Instant::now() >= deadline { - return status; - } - tokio::time::sleep(Duration::from_millis(200)).await; - } -} - -fn now_ms() -> i128 { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock before 1970") - .as_millis() as i128 -} - -/// The primitive: a pool with ONE bootstrap seed, no discovery, connects -/// and can `call` a real procedure against the real fleet. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn single_seed_pool_connects_and_calls_against_the_real_fleet() { - let identity = KeyPair::generate_with_default_puzzle(); - let pool = Pool::connect( - vec![Seed::new(STATION_HOST, STATION_PORT)], - Trust::WebPki, - identity, - PoolOptions::default(), - ); - - let status = wait_for_status(&pool, PoolStatus::is_healthy, Duration::from_secs(15)).await; - assert!( - status.is_healthy(), - "pool never completed its bootstrap handshake: {status:?}" - ); - - let deadline = now_ms() + 5_000; - let result = pool - .call( - "_dht.find_records_by_type", - [0u8; 32], - Value::Map(vec![(Value::text("type"), Value::Int(0x06))]), - deadline, - ) - .await; - assert!( - result.is_ok(), - "expected a real RESULT/ERROR, got {result:?}" - ); - - pool.close("normal", Some("test done")).await; -} - -/// Station discovery: a pool bootstrapped against ONE seed, with discovery -/// enabled, should find and connect to additional real fleet stations via -/// `hecate_stations.list_stations` — mirroring the identical live test in -/// macula-go's (`pool_discovery_live_test.go`) and macula-dotnet's -/// (`StationDiscoveryLiveTests.cs`) own ports of this feature. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn station_discovery_finds_and_connects_to_real_fleet_stations() { - let identity = KeyPair::generate_with_default_puzzle(); - let pool = Pool::connect( - vec![Seed::new(STATION_HOST, STATION_PORT)], - Trust::WebPki, - identity, - PoolOptions { - link_selection: LinkSelection::Auto, - station_discovery: StationDiscoveryOptions { - enabled: true, - refresh_interval: Duration::from_secs(3600), // one attempt is enough - max_links: 5, - }, - ..PoolOptions::default() - }, - ); - - let bootstrap_status = - wait_for_status(&pool, PoolStatus::is_healthy, Duration::from_secs(15)).await; - assert!( - bootstrap_status.is_healthy(), - "pool never completed its initial bootstrap handshake: {bootstrap_status:?}" - ); - - // Give the background discovery task time to run its first attempt - // (DHT lookup + list_stations call, both real network round trips) - // and for at least one discovered link to complete its own handshake. - let discovered_status = - wait_for_status(&pool, |s| s.healthy_links >= 2, Duration::from_secs(30)).await; - let links = pool.links().await; - assert!( - discovered_status.healthy_links >= 2, - "station discovery found no additional healthy stations against the real fleet \ - (status={discovered_status:?}, links={links:?}) -- either hecate_stations.list_stations \ - isn't currently advertised/visible from {STATION_HOST}, or discovery has a real bug" - ); - - pool.close("normal", Some("test done")).await; -} - -/// [`LinkSelection::Random`] actually rotates which link `call` tries -/// first, against two real, independently-dialed stations — not just the -/// pure-logic unit coverage in `pool.rs`'s own `#[cfg(test)]` module. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn random_link_selection_uses_more_than_one_link_against_the_real_fleet() { - let identity = KeyPair::generate_with_default_puzzle(); - let pool = Pool::connect( - vec![ - Seed::new(STATION_HOST, STATION_PORT), - Seed::new("station-it-milan.macula.io", 4433), - ], - Trust::WebPki, - identity, - PoolOptions { - link_selection: LinkSelection::Random, - ..PoolOptions::default() - }, - ); - - let status = wait_for_status(&pool, |s| s.healthy_links >= 2, Duration::from_secs(15)).await; - assert!( - status.healthy_links >= 2, - "expected both bootstrap seeds to come up healthy: {status:?}" - ); - - let mut responders = std::collections::HashSet::new(); - for _ in 0..20 { - let deadline = now_ms() + 5_000; - if let Ok(CallResponse::Result { responded_by, .. }) = pool - .call( - "_dht.find_records_by_type", - [0u8; 32], - Value::Map(vec![(Value::text("type"), Value::Int(0x06))]), - deadline, - ) - .await - { - responders.insert(responded_by); - } - } - assert!( - responders.len() >= 2, - "expected calls to be answered by at least 2 different stations under Random \ - selection across 20 calls, saw {}: {responders:?}", - responders.len() - ); - - pool.close("normal", Some("test done")).await; -} diff --git a/tests/live_station.rs b/tests/live_station.rs deleted file mode 100644 index 513b5d5..0000000 --- a/tests/live_station.rs +++ /dev/null @@ -1,2199 +0,0 @@ -//! Integration tests against real, live macula-station boxes. -//! -//! **Not run by default CI** — every test here is `#[ignore]`d, since it -//! depends on external infrastructure this crate doesn't own or control -//! (network reachability, the fleet's own uptime). Run explicitly with: -//! -//! ```text -//! cargo test --test live_station -- --ignored --nocapture -//! ``` -//! -//! **DNS gotcha, confirmed directly against the live box (2026-08-28):** -//! the bare `macula.io` hostname has an A (IPv4) record but genuinely no -//! AAAA record at all, while `macula-station-frankfurt`'s actual QUIC -//! listener (confirmed via `ss -ulnp` on the box itself) is bound to a -//! *specific* IPv6 address that has no relationship to the A record. -//! Dialing `macula.io` therefore resolves to a real, reachable IPv4 -//! address with nothing listening on port 4433 — every packet vanishes -//! silently (correct, spec-compliant QUIC behavior for unrecognized -//! traffic, indistinguishable from a firewalled port from the client -//! side alone). `station-de-frankfurt.macula.io` is the name that -//! actually resolves to the listener's real IPv6 address — this matches -//! the DNS-repoint gotcha already on file in project memory -//! (`reference_demo_fleet_boxes`), confirmed still true today. - -use macula_rust::cbor::Value; -use macula_rust::cert::ed25519_pubkey_from_cert; -use macula_rust::connection; -use macula_rust::identity::KeyPair; -use macula_rust::transport::{connect, Trust}; - -const STATION_HOST: &str = "station-de-frankfurt.macula.io"; -const STATION_PORT: u16 = 4433; - -/// `stations-linode-toronto`, provisioned 2026-08-29 specifically to have a -/// fleet member with no DNS entry and no CA-issued cert -- see -/// `macula-demo/infrastructure/stations-linode-toronto/`. Dialed by its bare -/// `host_advertised` IPv6 literal, never a hostname. -const TORONTO_HOST: &str = "2600:3c04::2000:f0ff:feb9:e155"; -const TORONTO_PORT: u16 = 4433; -const TORONTO_NODE_ID_HEX: &str = - "5748e81d89a6ea4b619fecda394ffac9f8f58a05d7a7234034783b6e1fd043d5"; - -const MILAN_HOST: &str = "station-it-milan.macula.io"; -const MILAN_PORT: u16 = 4433; - -/// Probe: dial with verification skipped, and report exactly what the -/// station presents (cert count, and its Ed25519 pubkey if the leaf is -/// Ed25519) — informational, not asserting a specific pubkey, since -/// that's fleet configuration this crate doesn't control and shouldn't -/// hardcode as a test expectation. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn probe_what_frankfurt_presents() { - let connection = connect(STATION_HOST, STATION_PORT, Trust::Insecure) - .await - .expect("QUIC/TLS handshake with ALPN=macula should succeed against a live station"); - - println!( - "connected: alpn={:?} remote={}", - connection - .handshake_data() - .and_then(|d| d.downcast::().ok()) - .and_then(|d| d.protocol) - .map(|p| String::from_utf8_lossy(&p).into_owned()), - connection.remote_address(), - ); - - let identity = connection - .peer_identity() - .expect("server cert chain should be present after a completed handshake"); - let certs = identity - .downcast::>>() - .expect("peer_identity for a rustls-backed QUIC connection is a cert chain"); - println!("station presented {} certificate(s)", certs.len()); - - let leaf = certs.first().expect("at least one cert in the chain"); - match ed25519_pubkey_from_cert(leaf.as_ref()) { - Ok(pubkey) => println!("leaf is Ed25519, pubkey = {}", hex::encode(pubkey)), - Err(e) => println!("leaf is NOT a bare Ed25519 SPKI cert: {e}"), - } - - connection.close(0u32.into(), b"probe complete"); -} - -/// **Empirical finding, 2026-08-28:** `macula-station-frankfurt` presents -/// a 3-certificate RSA chain (SPKI OID `1.2.840.113549.1.1.1`), not a -/// self-signed Ed25519 identity cert — confirmed directly via -/// `probe_what_frankfurt_presents` above. That matches macula's own -/// documented "public-IP path with Let's Encrypt-anchored certs" trust -/// mode exactly (`plans/PLAN_WIRE_PROTOCOL.md` §2's `verify => webpki`), -/// which is what this test exercises. Pubkey-pinned trust -/// (`Trust::Pinned`) is for macula's *other* documented deployment shape -/// — a station without public DNS/CA-issued TLS, identified by its raw -/// Ed25519 key instead — which no box in the current demo fleet happens -/// to be configured as. `PubkeyPinVerifier`'s own matching logic is still -/// fully covered, just as a local unit test against a synthetic cert -/// (`src/cert.rs`'s own tests), not a live one — see that module. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn webpki_trust_succeeds_against_the_real_fleet() { - let connection = connect(STATION_HOST, STATION_PORT, Trust::WebPki) - .await - .expect("CA-chain validation should succeed against a real Let's Encrypt cert"); - connection.close(0u32.into(), b"done"); -} - -/// The real milestone: not just a QUIC/TLS connection, but a complete -/// macula application-layer handshake — signed CONNECT out, verified -/// HELLO back, `accepted = true` — against a real production station. -/// Uses a **puzzle-hardened** identity deliberately: see -/// `plans/PLAN_WIRE_PROTOCOL.md` §5's callout on why an unhardened one -/// fails this silently (QUIC/TLS looks fine, the station just never -/// accepts). -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn full_handshake_succeeds_against_the_real_fleet() { - let identity = KeyPair::generate_with_default_puzzle(); - - let session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &identity) - .await - .expect("CONNECT/HELLO handshake should succeed against a live station"); - - println!( - "handshake accepted: remote={} station_node_id={} negotiated_capabilities={}", - session.remote_address(), - hex::encode(session.station.node_id), - session.station.negotiated_capabilities, - ); - assert!(session.station.accepted); - - session - .close("normal", Some("integration test done"), &identity) - .await; -} - -/// **Empirical finding, 2026-08-28 — contradicts the documented -/// expectation, recorded honestly rather than papered over.** The plan -/// (`plans/PLAN_WIRE_PROTOCOL.md` §5) and the production incident it's -/// based on both describe every station enforcing puzzle admission on -/// every CONNECT. Tested directly against `macula-station-frankfurt`: an -/// **unhardened identity was accepted** (`accepted = true`, same shape -/// as the hardened case). This crate's `puzzle_evidence` computation is -/// independently verified byte-for-byte against real Erlang -/// `crypto:hash/2` output (`src/identity.rs`'s own tests), so this isn't -/// a computation bug here — it means either (a) this specific dev-fleet -/// station has puzzle enforcement disabled or configured leniently (it's -/// documented elsewhere as throwaway dev infra, not production), (b) the -/// deployed image predates that enforcement, or (c) enforcement is -/// scoped to some condition this plain CONNECT doesn't trigger. Which -/// one is true is a `macula-station`-side question, out of scope for -/// this crate to chase — recorded here as a fact about what actually -/// happens against this fleet today, not a guarantee about macula's -/// protocol in general. **Always grind the puzzle regardless** (the cost -/// is negligible and it's clearly the intended, documented behavior) — -/// this test does not license skipping it. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn unhardened_identity_against_the_real_fleet_is_observed_not_assumed() { - let identity = KeyPair::generate(); // NOT puzzle-hardened, on purpose - - let result = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &identity).await; - match result { - Ok(session) => { - println!( - "OBSERVED: unhardened identity was ACCEPTED (accepted={}) -- see this \ - test's doc comment for why that's a fleet-configuration fact, not \ - evidence this crate's puzzle handling is wrong", - session.station.accepted - ); - session.close("normal", None, &identity).await; - } - Err(e) => { - println!("OBSERVED: unhardened identity was rejected, as: {e}"); - } - } -} - -/// A real end-to-end CALL/RESULT-or-ERROR round trip. Calls a procedure -/// name that certainly doesn't exist (`macula_rust.test_probe`, -/// under the content sentinel realm) — the point isn't to exercise any -/// particular procedure, only to prove the wire round trip itself: a -/// signed CALL sent, and a signed RESULT or ERROR received back, -/// correlated by call_id, with a real BOLT#4 code if it's an error. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn call_round_trip_against_the_real_fleet() { - let identity = KeyPair::generate_with_default_puzzle(); - let session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &identity) - .await - .expect("handshake should succeed"); - - let response = session - .call( - "macula_rust.test_probe", - [0u8; 32], // the content-sentinel realm, reused here as a harmless default - macula_rust::cbor::Value::Null, - (now_ms() + 10_000) as i128, - &identity, - std::time::Duration::from_secs(10), - ) - .await - .expect("should get SOME response (result or a well-formed error), not a timeout"); - - match response { - macula_rust::frame::CallResponse::Result { - payload, - responded_by, - } => { - println!("OBSERVED: got a RESULT (unexpected for a made-up procedure, but valid): payload={payload:?} responded_by={}", hex::encode(responded_by)); - } - macula_rust::frame::CallResponse::Error { - code, - name, - reported_by, - detail, - } => { - println!( - "OBSERVED: got an ERROR (expected for a nonexistent procedure): code={code} name={name} reported_by={} detail={detail:?}", - hex::encode(reported_by) - ); - } - } - - session - .close("normal", Some("call test done"), &identity) - .await; -} - -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock after epoch") - .as_millis() as u64 -} - -/// A real end-to-end SUBSCRIBE -> PUBLISH -> (maybe) EVENT round trip. -/// Whether a subscriber receives its own publish is genuinely unknown -/// going in — this test observes and reports rather than assuming an -/// answer, same discipline as the unhardened-identity test above. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn pubsub_round_trip_against_the_real_fleet() { - let identity = KeyPair::generate_with_default_puzzle(); - let session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &identity) - .await - .expect("handshake should succeed"); - - // A realm+topic scratch value nobody else would collide with. - let realm: [u8; 32] = rand::random(); - let topic = format!( - "macula-rust.test.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let mut subscription = session - .subscribe( - &macula_rust::frame::SubscribeSpec::new(topic.clone(), realm, identity.node_id()), - &identity, - ) - .await - .expect("SUBSCRIBE should send without error"); - - session - .publish( - &macula_rust::frame::PublishSpec::new( - topic.clone(), - realm, - identity.node_id(), - 1, - macula_rust::cbor::Value::text("hello from macula-rust"), - now_ms(), - ), - &identity, - ) - .await - .expect("PUBLISH should send without error"); - - match subscription - .recv_event(std::time::Duration::from_secs(5)) - .await - { - Ok(event) => { - println!( - "OBSERVED: received our own EVENT back — topic={} seq={} delivered_via={} payload={:?}", - event.topic, event.seq, event.delivered_via, event.payload - ); - assert_eq!(event.topic, topic); - } - Err(e) => { - println!( - "OBSERVED: no EVENT arrived within 5s ({e}) — a subscriber may not receive its \ - own publish, or delivery may simply be slower than this test waits. Not \ - asserted as a failure either way; see this test's doc comment." - ); - } - } - - session - .close("normal", Some("pubsub test done"), &identity) - .await; -} - -/// Real end-to-end proof that `run_subscriber`/`run_publisher` work, not -/// just compile — same discipline as `macula-go`'s -/// `TestLiveRunSubscriberAndRunPublisher`: three SEPARATE sessions/ -/// identities (this fleet kicks whichever connection reuses an identity -/// second, confirmed elsewhere this session), a subscriber genuinely -/// receiving a real event through its callback (not manual polling), and -/// the auto-published `pubsub.publish_completed_v1` fact confirmed by an -/// INDEPENDENT fourth session subscribed BEFORE the publish happens — not -/// the publisher's own bookkeeping. A random realm scopes this test's -/// traffic away from any real third-party activity on this shared public -/// fleet, same as `pubsub_round_trip_against_the_real_fleet` above. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn run_subscriber_and_run_publisher_against_the_real_fleet() { - let realm: [u8; 32] = rand::random(); - let topic = format!( - "macula-rust.test.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - // Independent watcher, subscribed to the fact topic BEFORE anything - // publishes -- pubsub has no replay for a late subscriber. - let watcher_id = KeyPair::generate_with_default_puzzle(); - let watcher = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &watcher_id) - .await - .expect("watcher handshake should succeed"); - let mut watcher_subscription = watcher - .subscribe( - &macula_rust::frame::SubscribeSpec::new( - "pubsub.publish_completed_v1", - realm, - watcher_id.node_id(), - ), - &watcher_id, - ) - .await - .expect("watcher SUBSCRIBE should send without error"); - - let sub_id = KeyPair::generate_with_default_puzzle(); - let sub_session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &sub_id) - .await - .expect("subscriber handshake should succeed"); - - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let sub_topic = topic.clone(); - let subscribe_task = tokio::spawn(async move { - let spec = macula_rust::frame::SubscribeSpec::new(sub_topic, realm, sub_id.node_id()); - let stop = tokio::time::sleep(std::time::Duration::from_secs(8)); - sub_session - .run_subscriber(&spec, &sub_id, stop, |evt| { - let _ = tx.send(evt); - }) - .await - }); - - // Give both SUBSCRIBEs a moment to actually land before publishing. - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - let pub_id = KeyPair::generate_with_default_puzzle(); - let pub_session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &pub_id) - .await - .expect("publisher handshake should succeed"); - let spec = macula_rust::frame::PublishSpec::new( - topic.clone(), - realm, - pub_id.node_id(), - 1, - Value::text("hello from run_publisher"), - now_ms(), - ); - let publish_result = pub_session.run_publisher(&spec, &pub_id, true).await; - assert!( - publish_result.is_ok(), - "run_publisher should succeed: {publish_result:?}" - ); - println!("OBSERVED: run_publisher completed cleanly"); - pub_session - .close("normal", Some("publisher test done"), &pub_id) - .await; - - match tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv()).await { - Ok(Some(evt)) => { - println!( - "OBSERVED: run_subscriber's handler received the real EVENT -- topic={} payload={:?}", - evt.topic, evt.payload - ); - assert_eq!(evt.topic, topic); - } - _ => println!( - "OBSERVED: no EVENT arrived via the subscriber's callback within 5s -- a subscriber \ - may not receive its own publish, same caveat as pubsub_round_trip_against_the_real_fleet" - ), - } - - let sub_result = subscribe_task - .await - .expect("subscriber task should not panic"); - assert!( - sub_result.is_ok(), - "run_subscriber should return Ok after its stop future resolves: {sub_result:?}" - ); - println!("OBSERVED: run_subscriber returned cleanly after its stop future resolved"); - - // The watcher's subscription only receives events for its own topic, under - // a realm nobody else uses; wait within an overall deadline for the fact - // to arrive. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - let mut confirmed = false; - while std::time::Instant::now() < deadline { - match watcher_subscription - .recv_event(std::time::Duration::from_secs(2)) - .await - { - Ok(evt) if evt.topic == "pubsub.publish_completed_v1" => { - let outcome = evt.payload.get("outcome"); - println!( - "OBSERVED: independent watcher confirmed a real pubsub.publish_completed_v1 fact landed -- outcome={outcome:?}" - ); - assert_eq!(outcome, Some(&Value::text("completed"))); - confirmed = true; - break; - } - Ok(other) => { - println!( - "(watcher skipping unrelated event on topic {})", - other.topic - ); - } - Err(e) => { - println!("(watcher still waiting: {e})"); - } - } - } - assert!( - confirmed, - "independent watcher never observed a pubsub.publish_completed_v1 fact within the deadline" - ); - - watcher - .close("normal", Some("watcher test done"), &watcher_id) - .await; -} - -/// Regression test for a real bug found live 2026-08-29 in the Go port -/// of this exact `connect -> write -> Close` shape (macula-go's -/// `connection.Session.Close`): a PUBLISH sent immediately before -/// `close` -- exactly what every one-shot CLI/tool invocation does -- -/// could be silently dropped, because `Connection::close` is abrupt and -/// does not wait for outstanding stream data to actually reach the -/// peer. `pubsub_round_trip_against_the_real_fleet` above can't catch -/// this: it keeps reading (blocking on `recv_event`) on the SAME -/// session that published, so `close` doesn't run until well after the -/// write already had time to flush. This uses two INDEPENDENT sessions -/// specifically so the publisher's `close` isn't incidentally delayed -/// by anything the subscriber does. Fixed proactively in -/// `Session::close` (`CLOSE_DRAIN`) before this was independently -/// rediscovered against this crate -- this test is what proves that -/// held. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn publish_survives_immediate_close_against_the_real_fleet() { - let sub_identity = KeyPair::generate_with_default_puzzle(); - let sub_session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &sub_identity) - .await - .expect("handshake should succeed (subscriber)"); - - let realm: [u8; 32] = rand::random(); - let topic = format!( - "macula-rust.test.immediate-close.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let mut subscription = sub_session - .subscribe( - &macula_rust::frame::SubscribeSpec::new(topic.clone(), realm, sub_identity.node_id()), - &sub_identity, - ) - .await - .expect("SUBSCRIBE should send without error"); - // Give the SUBSCRIBE a moment to register before the publish races - // it -- this test is about the PUBLISH-then-close race, not about - // subscribe-propagation timing (a separate concern). - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - // Separate connection, separate identity: publish then close - // immediately, no read in between -- the exact shape a one-shot - // CLI/tool invocation uses. - { - let pub_identity = KeyPair::generate_with_default_puzzle(); - let pub_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &pub_identity) - .await - .expect("handshake should succeed (publisher)"); - pub_session - .publish( - &macula_rust::frame::PublishSpec::new( - topic.clone(), - realm, - pub_identity.node_id(), - 1, - macula_rust::cbor::Value::text( - "hello from the immediate-close regression test", - ), - now_ms(), - ), - &pub_identity, - ) - .await - .expect("PUBLISH should send without error"); - pub_session.close("normal", None, &pub_identity).await; - } - - // The subscription only receives events for its own topic, so the first - // one within the deadline is the EVENT this test waits for. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - loop { - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - assert!( - !remaining.is_zero(), - "EVENT for our topic never arrived after a publish immediately followed by close \ - (this is the exact race this test exists to catch)" - ); - match subscription.recv_event(remaining).await { - Ok(event) if event.topic == topic => break, - Ok(event) => println!("skipping an unrelated EVENT: topic={}", event.topic), - Err(connection::RecvEventError::Timeout) => { - panic!( - "EVENT for our topic never arrived after a publish immediately followed by \ - close (this is the exact race this test exists to catch)" - ); - } - Err(e) => panic!("the subscription ended before the EVENT arrived: {e}"), - } - } - - sub_session - .close("normal", Some("immediate-close test done"), &sub_identity) - .await; -} - -/// A real single-block put/get round trip: content small enough -/// (`<= manifest::DEFAULT_CHUNK_SIZE`) to be addressed purely by content -/// hash, no manifest involved. Every byte is randomized per run so -/// there's no risk of colliding with content some other run already -/// stored under the same MCID. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn single_block_put_get_round_trip_against_the_real_fleet() { - let identity = KeyPair::generate_with_default_puzzle(); - let session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &identity) - .await - .expect("handshake should succeed"); - - let data: Vec = (0..4096).map(|_| rand::random::()).collect(); - let mcid = macula_rust::content::put(&session, &data, "test-block", &identity) - .await - .expect("put should succeed"); - assert!( - !macula_rust::manifest::mcid_is_chunked(&mcid), - "4096 bytes is well under the chunking threshold" - ); - println!( - "OBSERVED: stored single block under mcid={}", - hex::encode(mcid) - ); - - let fetched = macula_rust::content::get(&session, mcid, &identity) - .await - .expect("get should succeed for content this session just put"); - assert_eq!( - fetched, data, - "fetched bytes must match what was put, exactly" - ); - - session - .close("normal", Some("content single-block test done"), &identity) - .await; -} - -/// A real chunked put/get round trip: content large enough to force -/// `manifest::create`'s multi-chunk path, exercising `_content.put_block` -/// (several times, sequentially — see `src/content.rs`'s module doc on -/// why this crate doesn't parallelize lanes), `_content.put_manifest`, -/// `_content.get_manifest`, and `_content.get_block` (again several -/// times) all against a real station, then verifies the reassembled -/// bytes against the manifest's Merkle root. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn chunked_put_get_round_trip_against_the_real_fleet() { - let identity = KeyPair::generate_with_default_puzzle(); - let session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &identity) - .await - .expect("handshake should succeed"); - - let size = macula_rust::manifest::DEFAULT_CHUNK_SIZE * 2 + 12_345; - let data: Vec = (0..size).map(|_| rand::random::()).collect(); - let mcid = macula_rust::content::put(&session, &data, "test-chunked", &identity) - .await - .expect("chunked put should succeed"); - assert!( - macula_rust::manifest::mcid_is_chunked(&mcid), - "{size} bytes is well over the chunking threshold" - ); - println!( - "OBSERVED: stored {size} bytes as a manifest under mcid={}", - hex::encode(mcid) - ); - - let fetched = macula_rust::content::get(&session, mcid, &identity) - .await - .expect("chunked get should succeed for content this session just put"); - assert_eq!( - fetched, data, - "reassembled bytes must match what was put, exactly" - ); - - session - .close("normal", Some("content chunked test done"), &identity) - .await; -} - -/// A made-up MCID that (with overwhelming probability) nothing has ever -/// stored — proves the wire-level `not_found` reply is reached and -/// parsed correctly, not just the happy path. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn get_of_an_unknown_block_reports_not_found_against_the_real_fleet() { - let identity = KeyPair::generate_with_default_puzzle(); - let session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &identity) - .await - .expect("handshake should succeed"); - - let random_hash: [u8; 32] = rand::random(); - let mcid = macula_rust::manifest::block_mcid(&random_hash); - - match macula_rust::content::get(&session, mcid, &identity).await { - Err(macula_rust::content::GetError::NotFound) => { - println!("OBSERVED: not_found reported correctly for an unknown mcid"); - } - other => panic!("expected GetError::NotFound, got {other:?}"), - } - - session - .close("normal", Some("content not-found test done"), &identity) - .await; -} - -/// Proves the exact bug class `macula-station`'s mode-aware half-close -/// fix (commit `07db0d8`) addresses: a `client_stream` caller that -/// pushes its data, half-closes its own send side with `close_send`, -/// and then awaits the provider's reply. Before that fix, the relay -/// tore down the ENTIRE bidirectional stream route on the caller's -/// STREAM_END regardless of the wire's `role` field, so the provider's -/// `send_reply` returned no error locally while the caller's -/// `await_reply` timed out — this crate's SDK-side code was already -/// correct, the bug lived entirely in the station's relay. -/// -/// This deliberately does NOT reuse the shape the previous version of -/// this test had (a lone caller against a made-up, unregistered -/// procedure with no real provider): that only ever proved wire -/// mechanics, never actually exercised `send_reply`/`await_reply` -/// against a real counterpart, and a hand-written mock provider here -/// could too easily bake the old (buggy) relay behavior in as -/// "correct" without anyone noticing. Two independent connections to -/// the SAME real, live station — one provider, one caller — same -/// pattern as `streaming_provider_round_trip_against_the_real_fleet` -/// above, with the roles matched to `ClientStream`'s actual wire shape -/// instead of `ServerStream`'s: the CALLER pushes data and closes its -/// own send side, the PROVIDER drains with `recv` and finishes with -/// `send_reply`, and the caller's `await_reply` is what's actually -/// being proven. Matches `macula-go`'s own -/// `TestLiveClientStreamReplyRoundTrip` (`stream/live_test.go`), the -/// SDK that already had this shape right, including asserting the -/// actual reply payload and `responded_by` rather than just logging -/// whichever of the two possible outcomes happened to occur. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn client_stream_reply_round_trip_against_the_real_fleet() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake should succeed"); - let caller_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_identity) - .await - .expect("caller handshake should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_client_stream.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let advertise_spec = macula_rust::frame::AdvertiseSpec::new( - realm, - procedure.clone(), - provider_identity.node_id(), - ); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await - .expect("advertise should send"); - - // Give the station a moment to register the advertisement before - // the caller dials in against it. - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - let accept_task = tokio::spawn(async move { - let result = macula_rust::stream::StreamHandle::accept( - &provider_session, - std::time::Duration::from_secs(10), - ) - .await; - (result, provider_session) - }); - - let mut caller_handle = macula_rust::stream::StreamHandle::open( - &caller_session, - &procedure, - realm, - macula_rust::frame::StreamMode::ClientStream, - macula_rust::cbor::Value::Null, - (now_ms() + 10_000) as i128, - &caller_identity, - ) - .await - .expect("caller should open a stream"); - - let (accept_result, provider_session) = - accept_task.await.expect("accept task should not panic"); - let (mut provider_handle, open_info) = - accept_result.expect("provider should accept the inbound STREAM_OPEN"); - - println!( - "OBSERVED: provider accepted stream_open for procedure={} mode={:?}", - open_info.procedure, open_info.mode - ); - assert_eq!(open_info.procedure, procedure); - assert_eq!(open_info.mode, macula_rust::frame::StreamMode::ClientStream); - - caller_handle - .send_data( - macula_rust::frame::StreamEncoding::Raw, - macula_rust::cbor::Value::Bytes(b"hello from the caller".to_vec()), - &caller_identity, - ) - .await - .expect("caller should push a chunk"); - caller_handle - .close_send(&caller_identity) - .await - .expect("caller should close its send side"); - - match provider_handle - .recv(std::time::Duration::from_secs(5)) - .await - .expect("provider should receive the pushed chunk") - { - macula_rust::stream::StreamItem::Data { body, .. } => { - assert_eq!( - body, - macula_rust::cbor::Value::Bytes(b"hello from the caller".to_vec()) - ); - } - other => panic!("expected Data, got {other:?}"), - } - match provider_handle - .recv(std::time::Duration::from_secs(5)) - .await - .expect("provider should see end-of-stream") - { - macula_rust::stream::StreamItem::Eof => {} - other => panic!("expected Eof, got {other:?}"), - } - - provider_handle - .send_reply( - macula_rust::cbor::Value::Text("processed: hello from the caller".to_string()), - &provider_identity, - ) - .await - .expect("provider should send a reply"); - - let (payload, responded_by) = caller_handle - .await_reply(std::time::Duration::from_secs(5)) - .await - .expect( - "caller should receive the reply -- if this times out, macula-station's \ - mode-aware half-close fix (commit 07db0d8) is not live on this station", - ); - assert_eq!( - payload, - macula_rust::cbor::Value::Text("processed: hello from the caller".to_string()) - ); - assert_eq!(responded_by, provider_identity.node_id()); - println!( - "OBSERVED: caller received a real STREAM_REPLY through ClientStream mode: payload={payload:?} responded_by={}", - hex::encode(responded_by) - ); - - provider_session - .close("normal", Some("provider test done"), &provider_identity) - .await; - caller_session - .close("normal", Some("caller test done"), &caller_identity) - .await; -} - -/// The real point of §13.2's whole existence: two independent -/// connections to the SAME live station — one advertises a procedure -/// and accepts inbound streams for it (the provider role), the other -/// dials in and pushes/pulls data against it (the caller role, already -/// live-verified elsewhere). This is the first test in this crate where -/// this process is on the RECEIVING end of a mesh interaction it didn't -/// initiate — everything before this dialed out and waited for a -/// response; here, one session sits idle after `advertise` until the -/// station itself routes a stranger's request back to it. -/// -/// Same station on purpose: cross-station routing depends on gossip -/// propagation between stations, which isn't instant and isn't this -/// crate's concern to wait out — same-station is the direct case -/// `plans/PLAN_WIRE_PROTOCOL.md` §6.9 describes ("registers the handler -/// with the pool's advertise-gossip mechanism"), and it's what a real -/// provider dialed into one station actually needs day to day. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn streaming_provider_round_trip_against_the_real_fleet() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake should succeed"); - let caller_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_identity) - .await - .expect("caller handshake should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_provider.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let advertise_spec = macula_rust::frame::AdvertiseSpec::new( - realm, - procedure.clone(), - provider_identity.node_id(), - ); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await - .expect("advertise should send"); - - // Give the station a moment to register the advertisement before - // the caller dials in against it. - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - let accept_task = tokio::spawn(async move { - let result = macula_rust::stream::StreamHandle::accept( - &provider_session, - std::time::Duration::from_secs(10), - ) - .await; - (result, provider_session) - }); - - let mut caller_handle = macula_rust::stream::StreamHandle::open( - &caller_session, - &procedure, - realm, - macula_rust::frame::StreamMode::ServerStream, - macula_rust::cbor::Value::Null, - (now_ms() + 10_000) as i128, - &caller_identity, - ) - .await - .expect("caller should open a stream"); - - let (accept_result, provider_session) = - accept_task.await.expect("accept task should not panic"); - let (mut provider_handle, open_info) = - accept_result.expect("provider should accept the inbound STREAM_OPEN"); - - println!( - "OBSERVED: provider accepted stream_open for procedure={} mode={:?}", - open_info.procedure, open_info.mode - ); - assert_eq!(open_info.procedure, procedure); - assert_eq!(open_info.mode, macula_rust::frame::StreamMode::ServerStream); - - provider_handle - .send_data( - macula_rust::frame::StreamEncoding::Raw, - macula_rust::cbor::Value::Bytes(b"hello from the provider".to_vec()), - &provider_identity, - ) - .await - .expect("provider should push a chunk"); - provider_handle - .close_send(&provider_identity) - .await - .expect("provider should close its send side"); - - match caller_handle - .recv(std::time::Duration::from_secs(5)) - .await - .expect("caller should receive the pushed chunk") - { - macula_rust::stream::StreamItem::Data { body, .. } => { - assert_eq!( - body, - macula_rust::cbor::Value::Bytes(b"hello from the provider".to_vec()) - ); - } - other => panic!("expected Data, got {other:?}"), - } - match caller_handle - .recv(std::time::Duration::from_secs(5)) - .await - .expect("caller should see end-of-stream") - { - macula_rust::stream::StreamItem::Eof => {} - other => panic!("expected Eof, got {other:?}"), - } - - provider_session - .close("normal", Some("provider test done"), &provider_identity) - .await; - caller_session - .close("normal", Some("caller test done"), &caller_identity) - .await; -} - -/// The unary-RPC counterpart to `streaming_provider_round_trip_against_the_real_fleet` -/// above, and the gap this crate's own README used to list as "not yet -/// built": two independent connections to the SAME live station, one -/// advertising a procedure and serving inbound CALLs for it via -/// [`connection::Session::serve_one_call`], the other dialing in and -/// calling it — the caller role already covered by -/// `call_round_trip_against_the_real_fleet`. Without this, a service -/// built on this crate could call RPCs and serve streams, but could -/// never serve a request/response procedure at all. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn unary_call_provider_round_trip_against_the_real_fleet() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake should succeed"); - let caller_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_identity) - .await - .expect("caller handshake should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_add.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let advertise_spec = macula_rust::frame::AdvertiseSpec::new( - realm, - procedure.clone(), - provider_identity.node_id(), - ); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await - .expect("advertise should send"); - - // Give the station a moment to register the advertisement before - // the caller dials in against it. - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - let target_procedure = procedure.clone(); - let lookup = move |_realm: &[u8; 32], proc: &str| -> Option { - if proc != target_procedure { - return None; - } - let handler: connection::CallHandler = std::sync::Arc::new(|payload: Value| { - Box::pin(async move { - let a = match payload.get("a") { - Some(Value::Int(n)) => *n, - _ => return Err("missing or non-integer field \"a\"".to_string()), - }; - let b = match payload.get("b") { - Some(Value::Int(n)) => *n, - _ => return Err("missing or non-integer field \"b\"".to_string()), - }; - Ok(Value::Int(a + b)) - }) as connection::BoxFuture<'static, Result> - }); - Some(handler) - }; - - let serve_task = tokio::spawn(async move { - let result = provider_session - .serve_one_call( - lookup, - &provider_identity, - std::time::Duration::from_secs(15), - ) - .await; - (result, provider_session, provider_identity) - }); - - let payload = Value::Map(vec![ - (Value::text("a"), Value::Int(3)), - (Value::text("b"), Value::Int(4)), - ]); - let response = caller_session - .call( - &procedure, - realm, - payload, - (now_ms() + 10_000) as i128, - &caller_identity, - std::time::Duration::from_secs(10), - ) - .await - .expect("call should succeed"); - - let (serve_result, provider_session, provider_identity) = - serve_task.await.expect("serve task should not panic"); - serve_result.expect("provider should serve the inbound CALL"); - - match response { - macula_rust::frame::CallResponse::Result { payload, .. } => { - assert_eq!(payload, Value::Int(7), "3 + 4 should reply with RESULT 7"); - } - other => panic!("expected a RESULT, got {other:?}"), - } - println!( - "OBSERVED: provider served the inbound CALL for procedure={procedure}, caller got RESULT 7" - ); - - provider_session - .close( - "normal", - Some("unary provider test done"), - &provider_identity, - ) - .await; - caller_session - .close("normal", Some("unary caller test done"), &caller_identity) - .await; -} - -/// Regression test for a real bug found and root-caused 2026-09-05 -/// building the quickstart example: identical to -/// `unary_call_provider_round_trip_against_the_real_fleet` above, EXCEPT -/// `#[tokio::test]`'s default flavor there is single-threaded -/// (`current_thread`) -- this test forces the MULTI-threaded flavor -/// `#[tokio::main]` itself defaults to, which is what any real -/// consumer's `main` actually runs under. -/// -/// Root cause, confirmed by instrumenting `FrameStream::send_frame`/ -/// `recv_frame` with thread-id and frame-content logging against the -/// real fleet: it is NOT fundamentally about multi-threading. It's -/// [`Session`]'s own documented "always call `close` before this goes -/// out of scope" contract (see that type's own doc, and -/// [`Session::serve_one_call`]'s) -- a bare drop tears down the -/// connection with no guarantee the last write reached the peer, and a -/// `tokio::spawn`ed task with nothing after the `served_one_call().await` -/// drops its `Session` the instant the task completes. Under a -/// multi-threaded runtime that spawned task can run to completion (and -/// drop the session) within microseconds of the write -- deterministically, -/// every run in this environment -- while a single-threaded runtime's own -/// cooperative scheduling happens to leave enough incidental delay before -/// the drop for quinn's send-scheduling to flush first. This test -/// deliberately closes `provider_session` explicitly before the task -/// ends, exactly like `unary_call_provider_round_trip_against_the_real_fleet` -/// above already does (returning the session out of the spawned task and -/// closing it afterward is equally correct) -- with that discipline -/// applied, the round trip is exactly as reliable under multi-thread as -/// under current_thread. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "requires network access to a live macula-station"] -async fn unary_call_provider_round_trip_multi_thread_runtime() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake should succeed"); - let caller_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_identity) - .await - .expect("caller handshake should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_multithread.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let advertise_spec = macula_rust::frame::AdvertiseSpec::new( - realm, - procedure.clone(), - provider_identity.node_id(), - ); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await - .expect("advertise should send"); - - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - let target_procedure = procedure.clone(); - let lookup = move |_realm: &[u8; 32], proc: &str| -> Option { - if proc != target_procedure { - return None; - } - let handler: connection::CallHandler = std::sync::Arc::new(|payload: Value| { - Box::pin(async move { Ok(payload) }) - as connection::BoxFuture<'static, Result> - }); - Some(handler) - }; - - let serve_task = tokio::spawn(async move { - let result = provider_session - .serve_one_call( - lookup, - &provider_identity, - std::time::Duration::from_secs(15), - ) - .await; - // The fix: close explicitly instead of letting provider_session - // drop when this task ends -- see this test's own doc comment. - provider_session - .close( - "normal", - Some("multi-thread provider test done"), - &provider_identity, - ) - .await; - result - }); - - let response = caller_session - .call( - &procedure, - realm, - Value::text("hello"), - (now_ms() + 10_000) as i128, - &caller_identity, - std::time::Duration::from_secs(10), - ) - .await - .expect("call should succeed"); - - let serve_result = serve_task.await.expect("serve task should not panic"); - serve_result.expect("provider should serve the inbound CALL"); - - match response { - macula_rust::frame::CallResponse::Result { payload, .. } => { - assert_eq!(payload, Value::text("hello")); - } - other => panic!("expected a RESULT, got {other:?}"), - } - - caller_session - .close( - "normal", - Some("multi-thread caller test done"), - &caller_identity, - ) - .await; -} - -/// Proves `call`/`serve_one_call` genuinely auto-publish `rpc.sent_v1`/ -/// `rpc.completed_v1` (caller) and `rpc.received_v1`/`rpc.replied_v1` -/// (provider) — confirmed by an INDEPENDENT watcher session, not the -/// caller's/provider's own bookkeeping. A random realm (same trick -/// `unary_call_provider_round_trip_against_the_real_fleet` and the pubsub -/// live test already use) scopes every fact this test's own call -/// generates away from any real third-party activity on this shared -/// public fleet — with only one call made under a realm nobody else -/// uses, there is exactly one of each fact to expect, so no request_id -/// correlation against unrelated traffic is needed here (unlike -/// `macula-go`'s equivalent test, which had to add that specifically -/// because it published under a FIXED, shared topic/realm). -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn rpc_telemetry_facts_against_the_real_fleet() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - let watcher_identity = KeyPair::generate_with_default_puzzle(); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_rpc_facts.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - // Watcher subscribes to all 4 topics BEFORE anything happens — pubsub - // has no replay for a late subscriber. - let watcher = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &watcher_identity) - .await - .expect("watcher handshake should succeed"); - let mut subscriptions = Vec::new(); - for topic in [ - "rpc.sent_v1", - "rpc.completed_v1", - "rpc.received_v1", - "rpc.replied_v1", - ] { - subscriptions.push( - watcher - .subscribe( - &macula_rust::frame::SubscribeSpec::new( - topic, - realm, - watcher_identity.node_id(), - ), - &watcher_identity, - ) - .await - .expect("watcher SUBSCRIBE should send without error"), - ); - } - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake should succeed"); - let caller_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_identity) - .await - .expect("caller handshake should succeed"); - - let advertise_spec = macula_rust::frame::AdvertiseSpec::new( - realm, - procedure.clone(), - provider_identity.node_id(), - ); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await - .expect("advertise should send"); - - // Give both the advertise and the 4 SUBSCRIBEs a moment to land. - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - let target_procedure = procedure.clone(); - let lookup = move |_realm: &[u8; 32], proc: &str| -> Option { - if proc != target_procedure { - return None; - } - Some(std::sync::Arc::new(|payload: Value| { - Box::pin(async move { Ok(payload) }) - as connection::BoxFuture<'static, Result> - })) - }; - - // Returns provider_session/provider_identity back out, rather than - // letting them drop when the task ends, so they can be explicitly - // `.close()`d below — NOT a stylistic choice. A bare `Drop` right - // after `serve_one_call` returns races the just-sent RESULT frame - // against abrupt QUIC connection teardown with zero drain time, - // exactly the "PUBLISH sent immediately before Close intermittently - // never reached the peer" gotcha `Session::close`'s own doc already - // warns about (quinn's `write_all`/`finish` only hand data to its - // send-scheduling machinery, they don't wait for the peer to receive - // it) — except a bare drop has even less margin than `close()`'s own - // built-in drain sleep. Found live: an earlier draft of this test let - // `provider_session` drop bare and got a deterministic, 100%-reproducible - // caller-side timeout even though `serve_one_call` itself returned - // `Ok(())` every time — isolated by bisection against - // `unary_call_provider_round_trip_against_the_real_fleet` (which - // already returns its sessions and explicitly closes them, and never - // hits this), not a fleet flake and not caused by the RPC telemetry - // facts this test actually exists to check. - let serve_task = tokio::spawn(async move { - let result = provider_session - .serve_one_call( - lookup, - &provider_identity, - std::time::Duration::from_secs(15), - ) - .await; - (result, provider_session, provider_identity) - }); - - let response = caller_session - .call( - &procedure, - realm, - Value::text("hello"), - (now_ms() + 10_000) as i128, - &caller_identity, - std::time::Duration::from_secs(10), - ) - .await - .expect("call should succeed"); - assert!( - matches!(response, macula_rust::frame::CallResponse::Result { .. }), - "expected a RESULT, got {response:?}" - ); - - let (serve_result, provider_session, provider_identity) = - serve_task.await.expect("serve task should not panic"); - serve_result.expect("provider should serve the inbound CALL"); - provider_session - .close("normal", Some("rpc facts test done"), &provider_identity) - .await; - - let mut seen = std::collections::HashSet::new(); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - while seen.len() < 4 && std::time::Instant::now() < deadline { - for subscription in subscriptions.iter_mut() { - let Ok(evt) = subscription - .recv_event(std::time::Duration::from_millis(250)) - .await - else { - continue; - }; - if seen.insert(evt.topic.clone()) { - println!( - "OBSERVED: {} fact landed with payload={:?}", - evt.topic, evt.payload - ); - } - } - } - assert_eq!( - seen, - std::collections::HashSet::from([ - "rpc.sent_v1".to_string(), - "rpc.completed_v1".to_string(), - "rpc.received_v1".to_string(), - "rpc.replied_v1".to_string(), - ]), - "expected all 4 RPC telemetry facts to land, only saw: {seen:?}" - ); - - caller_session - .close("normal", Some("rpc facts test done"), &caller_identity) - .await; - watcher - .close("normal", Some("rpc facts test done"), &watcher_identity) - .await; -} - -/// Confirms the BOLT#4 error path: a provider that's advertised but -/// whose lookup (deliberately, here) can't find a handler replies with -/// the exact same `unknown_next_peer` code the reference sends for this -/// race (`macula_station_link.erl`'s `handle_inbound_call/2`, "unknown -/// (realm, procedure)" branch). -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn unary_call_provider_reports_unknown_next_peer_on_lookup_miss_against_the_real_fleet() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake should succeed"); - let caller_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_identity) - .await - .expect("caller handshake should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_miss.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let advertise_spec = macula_rust::frame::AdvertiseSpec::new( - realm, - procedure.clone(), - provider_identity.node_id(), - ); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await - .expect("advertise should send"); - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - let no_handlers = |_realm: &[u8; 32], _proc: &str| -> Option { None }; - let serve_task = tokio::spawn(async move { - let result = provider_session - .serve_one_call( - no_handlers, - &provider_identity, - std::time::Duration::from_secs(15), - ) - .await; - (result, provider_session, provider_identity) - }); - - let response = caller_session - .call( - &procedure, - realm, - Value::Null, - (now_ms() + 10_000) as i128, - &caller_identity, - std::time::Duration::from_secs(10), - ) - .await - .expect("call should succeed"); - - let (serve_result, provider_session, provider_identity) = - serve_task.await.expect("serve task should not panic"); - serve_result.expect("provider should serve the inbound CALL (with an error reply)"); - - match response { - macula_rust::frame::CallResponse::Error { code, name, .. } => { - assert_eq!(code, macula_rust::bolt4::Code::UnknownNextPeer.as_u8()); - println!("OBSERVED: lookup miss correctly reported as ERROR code={code} name={name}"); - } - other => panic!("expected an ERROR, got {other:?}"), - } - - provider_session - .close( - "normal", - Some("unary provider miss test done"), - &provider_identity, - ) - .await; - caller_session - .close( - "normal", - Some("unary caller miss test done"), - &caller_identity, - ) - .await; -} - -/// **First-ever live test of `Trust::Pinned` against a real station.** -/// Every other test in this file dials `station-de-frankfurt.macula.io` -/// under `Trust::WebPki`, because that's the only trust mode Frankfurt's -/// CA-issued cert can satisfy -- `Trust::Pinned` had unit coverage only -/// (`src/cert.rs`, a synthetic cert), never a real handshake. Toronto -/// exists specifically to close that gap: no DNS entry, no CA cert, dialed -/// by its bare `host_advertised` IPv6 literal and validated by pinning its -/// known Ed25519 NodeId instead of a certificate chain -- exactly the -/// "station without public DNS/CA-issued TLS" mode `Trust::Pinned`'s own -/// doc comment describes as the normal case for a mobile client dialing a -/// known station. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn pinned_trust_full_handshake_succeeds_against_toronto() { - let node_id: [u8; 32] = hex::decode(TORONTO_NODE_ID_HEX) - .expect("valid hex") - .try_into() - .expect("32 bytes"); - let identity = KeyPair::generate_with_default_puzzle(); - - let session = connection::connect( - TORONTO_HOST, - TORONTO_PORT, - Trust::Pinned(node_id), - &identity, - ) - .await - .expect("Pinned-trust CONNECT/HELLO handshake should succeed against a live no-DNS station"); - - println!( - "handshake accepted: remote={} station_node_id={} negotiated_capabilities={}", - session.remote_address(), - hex::encode(session.station.node_id), - session.station.negotiated_capabilities, - ); - assert!(session.station.accepted); - assert_eq!( - session.station.node_id, node_id, - "the station's own reported node_id should match the one we pinned" - ); - - session - .close("normal", Some("pinned trust test done"), &identity) - .await; -} - -/// The primitive a real cam2me call would ride on -- two independent -/// identities each dialed into a DIFFERENT station (Frankfurt, Milan, -/// mirroring an actual two-emulator cam2me session run 2026-08-29: one -/// phone left on its default station, the other switched to Milan via -/// Settings), one advertising and accepting a bidirectional stream, the -/// other opening it and both sides exchanging data -- unlike -/// `streaming_provider_round_trip_against_the_real_fleet` above, which is -/// deliberately same-station because "cross-station routing depends on -/// gossip propagation between stations, which isn't instant and isn't this -/// crate's concern to wait out". This test IS concerned with exactly that: -/// it's the one open question a real call feature can't avoid, since two -/// contacts are never guaranteed to share a station. `StreamMode::Bidi` -/// rather than the one-directional `ServerStream` used above, since a call -/// needs both directions, not one. -/// -/// **Root cause found and fixed, 2026-08-29 -- this crate's bug, not -/// `macula-station`'s.** First run of this test found STREAM_OPEN routing -/// cross-station correctly but a DATA frame sent afterward never arriving, -/// reproducible at both 5s and 25s timeouts. Traced into -/// `macula_station_peer_observer.erl`'s dedicated-stream relay -/// (`verify_dedicated/4`): non-OPEN stream frames (DATA/END/ERROR) verify -/// against an optional `signer` field when present, falling back to "the -/// connection this frame arrived on" when absent -- and the reference's own -/// comment on that fallback says outright it's "single-hop only", because -/// at a second relay hop the connection belongs to the RELAYING STATION, -/// not the original sender. This crate's STREAM_DATA/END/ERROR -/// constructors never stamped `signer` at all (`frame.rs`'s own prior doc -/// comment reasoned "a direct-dial client... has no relay hop to -/// authenticate across" -- true of the client's OWN single hop, false of -/// what the STATION does with it afterward). Fixed: `StreamDataSpec`/ -/// `StreamEndSpec`/`StreamErrorSpec` all gained `signer: Option<[u8; 32]>`, -/// and every real call site (`StreamHandle::send_data`/`close_send`/ -/// `abort`) now always supplies `Some(identity.public_bytes())`. New -/// differential vectors added in `frame.rs` for the `Some` case, generated -/// live against `macula_frame:stream_data/1` etc with `signer` in the spec -/// map -- the two pre-existing vectors testing `None` are untouched and -/// still pass, since that's a real, still-valid branch of the reference's -/// own optional field. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn cross_station_streaming_round_trip_frankfurt_provider_milan_caller() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake against Frankfurt should succeed"); - let caller_session = - connection::connect(MILAN_HOST, MILAN_PORT, Trust::WebPki, &caller_identity) - .await - .expect("caller handshake against Milan should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_call.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let advertise_spec = macula_rust::frame::AdvertiseSpec::new( - realm, - procedure.clone(), - provider_identity.node_id(), - ); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await - .expect("advertise on Frankfurt should send"); - - // Same-station tests above give this 500ms; a cross-station lookup has - // to actually reach the other station first, so this waits longer - // before concluding it never will. - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - - let accept_task = tokio::spawn(async move { - let result = macula_rust::stream::StreamHandle::accept( - &provider_session, - std::time::Duration::from_secs(15), - ) - .await; - (result, provider_session) - }); - - let open_result = macula_rust::stream::StreamHandle::open( - &caller_session, - &procedure, - realm, - macula_rust::frame::StreamMode::Bidi, - macula_rust::cbor::Value::Null, - (now_ms() + 10_000) as i128, - &caller_identity, - ) - .await; - - let mut caller_handle = match open_result { - Ok(h) => h, - Err(e) => { - println!( - "OBSERVED: cross-station STREAM_OPEN failed as: {e} -- Milan could not \ - route to a procedure only advertised on Frankfurt within 5s. This is the \ - real, useful answer to whether a call feature can rely on cross-station \ - routing working promptly; see this test's doc comment." - ); - let _ = accept_task.await; - return; - } - }; - println!("OBSERVED: cross-station STREAM_OPEN succeeded -- Milan routed it to Frankfurt"); - - let (accept_result, provider_session) = - accept_task.await.expect("accept task should not panic"); - let (mut provider_handle, open_info) = - accept_result.expect("provider should accept the inbound STREAM_OPEN"); - assert_eq!(open_info.procedure, procedure); - assert_eq!(open_info.mode, macula_rust::frame::StreamMode::Bidi); - - // Send both frames, then DRAIN both (recv the Data) before either side - // closes its send half -- closing before the peer has drained the data - // that preceded the close is exactly the ordering the first run of - // this test got wrong (a real bug in this test, not in the SDK): - // provider_handle.recv() failed with StreamClosed because both sides - // half-closed before either had received the other's frame. - caller_handle - .send_data( - macula_rust::frame::StreamEncoding::Raw, - macula_rust::cbor::Value::Bytes(b"audio frame from phone2 (milan)".to_vec()), - &caller_identity, - ) - .await - .expect("caller should push a frame"); - provider_handle - .send_data( - macula_rust::frame::StreamEncoding::Raw, - macula_rust::cbor::Value::Bytes(b"audio frame from phone1 (frankfurt)".to_vec()), - &provider_identity, - ) - .await - .expect("provider should push a frame"); - - match provider_handle - .recv(std::time::Duration::from_secs(5)) - .await - .expect( - "provider should receive the caller's frame -- see this test's doc comment, \ - fixed 2026-08-29 by stamping `signer` on stream data frames", - ) { - macula_rust::stream::StreamItem::Data { body, .. } => { - assert_eq!( - body, - macula_rust::cbor::Value::Bytes(b"audio frame from phone2 (milan)".to_vec()) - ); - println!("OBSERVED: provider (Frankfurt) received phone2's frame from Milan"); - } - other => panic!("expected Data, got {other:?}"), - } - match caller_handle - .recv(std::time::Duration::from_secs(5)) - .await - .expect("caller should receive the provider's frame") - { - macula_rust::stream::StreamItem::Data { body, .. } => { - assert_eq!( - body, - macula_rust::cbor::Value::Bytes(b"audio frame from phone1 (frankfurt)".to_vec()) - ); - println!("OBSERVED: caller (Milan) received phone1's frame from Frankfurt"); - } - other => panic!("expected Data, got {other:?}"), - } - - caller_handle - .close_send(&caller_identity) - .await - .expect("caller should half-close"); - provider_handle - .close_send(&provider_identity) - .await - .expect("provider should half-close"); - - provider_session - .close( - "normal", - Some("cross-station call test done"), - &provider_identity, - ) - .await; - caller_session - .close( - "normal", - Some("cross-station call test done"), - &caller_identity, - ) - .await; -} - -/// Follow-up to `cross_station_streaming_round_trip_frankfurt_provider_milan_caller` -/// above, asking a narrower question: that test found STREAM_OPEN routes -/// cross-station but DATA on the resulting stream does not. -/// `plans/PLAN_MACULA_STREAMING.md` (macula-architecture) says cross-relay -/// STREAM_OPEN routing "will follow the CALL path's procedure-resolver -/// pattern" -- so if plain CALL/RESULT (a single request/response, no -/// persistent per-stream relay state to pin across the station boundary) -/// also crosses stations cleanly, that's real signal that a signaling -/// exchange (SDP offer/answer, ICE candidates) built on CALL rather than a -/// long-lived STREAM_OPEN session would not hit the same gap. -/// -/// **Empirical finding, 2026-08-29, confirmed:** it does not hit the gap. -/// Milan's CALL reached Frankfurt's advertised provider, the RESULT came -/// back with the exact expected payload, round trip in ~5s (almost all of -/// it the propagation wait, not the call itself). Unlike the streaming -/// case, there's no follow-up DATA frame to lose -- a CALL is one -/// request, one response, both riding the same resolver lookup that -/// already proved reliable for STREAM_OPEN. So a signaling exchange built -/// on CALL/RESULT (or short-lived RPCs generally) rather than a -/// persistent STREAM_OPEN+DATA session is on solid ground cross-station, -/// independent of whether the streaming DATA-relay gap above ever gets -/// fixed. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn cross_station_unary_call_round_trip_frankfurt_provider_milan_caller() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake against Frankfurt should succeed"); - let caller_session = - connection::connect(MILAN_HOST, MILAN_PORT, Trust::WebPki, &caller_identity) - .await - .expect("caller handshake against Milan should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_signal.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let advertise_spec = macula_rust::frame::AdvertiseSpec::new( - realm, - procedure.clone(), - provider_identity.node_id(), - ); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await - .expect("advertise on Frankfurt should send"); - - // Same wait the streaming counterpart used for its own resolver lookup. - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - - let target_procedure = procedure.clone(); - let lookup = move |_realm: &[u8; 32], proc: &str| -> Option { - if proc != target_procedure { - return None; - } - let handler: connection::CallHandler = std::sync::Arc::new(|payload: Value| { - Box::pin(async move { - match payload { - Value::Text(s) if s == "offer from phone2 (milan)" => { - Ok(Value::text("answer from phone1 (frankfurt)")) - } - other => Err(format!("unexpected payload: {other:?}")), - } - }) as connection::BoxFuture<'static, Result> - }); - Some(handler) - }; - - let serve_task = tokio::spawn(async move { - let result = provider_session - .serve_one_call( - lookup, - &provider_identity, - std::time::Duration::from_secs(20), - ) - .await; - (result, provider_session, provider_identity) - }); - - let response = caller_session - .call( - &procedure, - realm, - Value::text("offer from phone2 (milan)"), - (now_ms() + 15_000) as i128, - &caller_identity, - std::time::Duration::from_secs(15), - ) - .await; - - let (serve_result, provider_session, provider_identity) = - serve_task.await.expect("serve task should not panic"); - - match (response, serve_result) { - (Ok(macula_rust::frame::CallResponse::Result { payload, .. }), Ok(())) => { - let matches = payload == Value::text("answer from phone1 (frankfurt)"); - println!( - "OBSERVED: cross-station CALL/RESULT succeeded -- Milan's CALL reached \ - Frankfurt's provider and the RESULT came back, content matches = {matches}" - ); - } - (Ok(other), serve_result) => { - println!( - "OBSERVED: cross-station CALL got a response but not the expected RESULT: \ - {other:?} (serve_result={serve_result:?})" - ); - } - (Err(e), serve_result) => { - println!( - "OBSERVED: cross-station CALL failed -- {e} (serve_result={serve_result:?}). \ - If this fails the same way the streaming test's DATA phase did, the CALL \ - path shares the same cross-station gap; if it succeeds, signaling built on \ - CALL rather than STREAM_OPEN+DATA is on solid ground." - ); - } - } - - provider_session - .close( - "normal", - Some("cross-station signaling test done"), - &provider_identity, - ) - .await; - caller_session - .close( - "normal", - Some("cross-station signaling test done"), - &caller_identity, - ) - .await; -} - -/// Full direct-dial loop, end to end: a provider advertises via -/// `direct_dial::advertise_direct` (publishing a signed DHT record, not -/// the ordinary gossip ADVERTISE), a caller resolves that record over a -/// SEPARATE connection via `direct_dial::resolve`, and `direct_dial::call` -/// dials the resolved station directly and gets a REAL RESULT back — -/// proving the whole chain (sign, publish, resolve, verify the trust -/// chain, dial, call, serve) works against the real fleet, not just that -/// resolution reaches the call stage. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn direct_dial_advertise_resolve_and_call_round_trip_against_the_real_fleet() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = - connection::connect(MILAN_HOST, MILAN_PORT, Trust::WebPki, &provider_identity) - .await - .expect("provider handshake should succeed"); - // Used only to query the DHT -- per direct_dial::resolve's own doc, it - // does not need to be connected to the same station that ends up - // serving the call. Dialing a DIFFERENT station than the provider's - // own makes that claim meaningful rather than accidentally true. - let resolve_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_identity) - .await - .expect("resolve-side handshake should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_direct_dial.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - macula_rust::direct_dial::advertise_direct( - &provider_session, - &provider_identity, - realm, - &procedure, - std::time::Duration::from_secs(120), - ) - .await - .expect("advertise_direct should publish the DHT record"); - - let target_procedure = procedure.clone(); - let lookup = move |_realm: &[u8; 32], proc: &str| -> Option { - if proc != target_procedure { - return None; - } - let handler: connection::CallHandler = std::sync::Arc::new(|payload: Value| { - Box::pin(async move { - let n = match payload.get("n") { - Some(Value::Int(n)) => *n, - _ => return Err("missing or non-integer field \"n\"".to_string()), - }; - Ok(Value::Int(n * 2)) - }) as connection::BoxFuture<'static, Result> - }); - Some(handler) - }; - - let serve_task = tokio::spawn(async move { - let result = provider_session - .serve_one_call( - lookup, - &provider_identity, - std::time::Duration::from_secs(20), - ) - .await; - (result, provider_session, provider_identity) - }); - - let payload = Value::Map(vec![(Value::text("n"), Value::Int(21))]); - let response = macula_rust::direct_dial::call( - &resolve_session, - &caller_identity, - realm, - &procedure, - payload, - std::time::Duration::from_secs(15), - ) - .await - .expect("direct-dial call should resolve, dial, and complete"); - - let (serve_result, provider_session, provider_identity) = - serve_task.await.expect("serve task should not panic"); - serve_result.expect("provider should serve the direct-dialed inbound CALL"); - - match response { - macula_rust::frame::CallResponse::Result { payload, .. } => { - assert_eq!( - payload, - Value::Int(42), - "21 * 2 should reply with RESULT 42" - ); - } - other => panic!("expected a RESULT, got {other:?}"), - } - println!( - "OBSERVED: direct-dial resolved+dialed a station DIFFERENT from the resolve session's own, and got a real RESULT for procedure={procedure}" - ); - - provider_session - .close( - "normal", - Some("direct-dial provider test done"), - &provider_identity, - ) - .await; - resolve_session - .close( - "normal", - Some("direct-dial resolve-side test done"), - &caller_identity, - ) - .await; -} - -/// A direct call runs on the caller's own session to the provider's station -/// instead of dialing a second connection under the same identity, which -/// would make the station close that session. The name matches the .NET and -/// Go tests. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn resolver_session_still_connected_after_a_direct_call() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - let realm = [0u8; 32]; - let procedure = format!( - "macula_rust.direct_dial_reuse_test.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake should succeed"); - macula_rust::direct_dial::advertise_direct( - &provider_session, - &provider_identity, - realm, - &procedure, - std::time::Duration::from_secs(3600), - ) - .await - .expect("advertise_direct should publish the DHT record"); - let provider_station = provider_session.station.node_id; - - let target_procedure = procedure.clone(); - let lookup = move |_realm: &[u8; 32], proc: &str| -> Option { - if proc != target_procedure { - return None; - } - let echo: connection::CallHandler = std::sync::Arc::new(|payload: Value| { - Box::pin(async move { Ok(payload) }) - as connection::BoxFuture<'static, Result> - }); - Some(echo) - }; - let serve_task = tokio::spawn(async move { - let result = provider_session - .serve_one_call( - lookup, - &provider_identity, - std::time::Duration::from_secs(20), - ) - .await; - (result, provider_session, provider_identity) - }); - - let resolver = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_identity) - .await - .expect("resolver handshake should succeed"); - // Reuse needs the resolver on the provider's own station. - assert_eq!( - hex::encode(resolver.station.node_id), - hex::encode(provider_station), - "the resolver and the provider reached different stations" - ); - - let response = macula_rust::direct_dial::call( - &resolver, - &caller_identity, - realm, - &procedure, - Value::text("hello again"), - std::time::Duration::from_secs(15), - ) - .await - .expect("the direct call should be answered"); - match response { - macula_rust::frame::CallResponse::Result { payload, .. } => { - assert_eq!(payload, Value::text("hello again")); - } - other => panic!("expected a RESULT, got {other:?}"), - } - let (serve_result, provider_session, provider_identity) = - serve_task.await.expect("serve task should not panic"); - serve_result.expect("provider should serve the direct call"); - - // A second connection under caller_identity would have made the station - // close the resolver by now. - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - assert!( - resolver.end_reason().is_none(), - "the resolver session ended: {:?}", - resolver.end_reason() - ); - // A plain DHT query, so a stale station_endpoint record on the fleet - // can't fail the check. - let advertisements = macula_rust::dht::find_records( - &resolver, - &caller_identity, - macula_rust::dht::procedure_key(&macula_rust::dht::discovery_uri(realm, &procedure)), - ) - .await - .expect("the resolver still answers DHT queries"); - assert!( - !advertisements.is_empty(), - "the resolver found no advertisement for {procedure}" - ); - - provider_session - .close( - "normal", - Some("direct-dial reuse provider done"), - &provider_identity, - ) - .await; - resolver - .close( - "normal", - Some("direct-dial reuse resolver done"), - &caller_identity, - ) - .await; -} - -/// Proves `direct_dial::keep_advertised_direct` genuinely re-publishes on -/// each tick (not a no-op) and stops cleanly once told to. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn keep_advertised_direct_republishes_against_the_real_fleet() { - // Two independent identities on purpose: `publisher_identity` is owned - // by the spawned loop task; `reader_identity` is only ever used by this - // test's own verifying reads. `_dht.find_record` doesn't care who's - // asking, so there's no need to share one identity across an await - // boundary that would otherwise fight the loop task for ownership. - let publisher_identity = KeyPair::generate_with_default_puzzle(); - let reader_identity = KeyPair::generate_with_default_puzzle(); - - let reader_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &reader_identity) - .await - .expect("reader handshake should succeed"); - let loop_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &publisher_identity, - ) - .await - .expect("loop session handshake should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_keep_advertised.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - let uri = macula_rust::dht::discovery_uri(realm, &procedure); - let key = macula_rust::dht::procedure_key(&uri); - - let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>(); - let loop_procedure = procedure.clone(); - let loop_task = tokio::spawn(async move { - macula_rust::direct_dial::keep_advertised_direct( - &loop_session, - &publisher_identity, - realm, - &loop_procedure, - std::time::Duration::from_secs(120), - std::time::Duration::from_millis(500), - async move { - let _ = stop_rx.await; - }, - |e| eprintln!("keep_advertised_direct tick failed (non-fatal): {e}"), - ) - .await; - (loop_session, publisher_identity) - }); - - // Give the first (immediate) tick time to land, then read it back. - tokio::time::sleep(std::time::Duration::from_millis(300)).await; - let first = macula_rust::dht::find_record(&reader_session, &reader_identity, key) - .await - .expect("first tick should already be visible"); - - // Wait past a second tick and confirm the record genuinely changed -- - // not a stale read of the same one. - tokio::time::sleep(std::time::Duration::from_millis(700)).await; - let second = macula_rust::dht::find_record(&reader_session, &reader_identity, key) - .await - .expect("second tick should be visible"); - assert!( - second.created_at > first.created_at, - "expected the second tick's created_at ({}) to be strictly after the first's ({})", - second.created_at, - first.created_at - ); - println!( - "OBSERVED: created_at advanced from {} to {} across two KeepAdvertisedDirect ticks", - first.created_at, second.created_at - ); - - stop_tx - .send(()) - .expect("loop task should still be listening for stop"); - let (loop_session, publisher_identity) = - tokio::time::timeout(std::time::Duration::from_secs(5), loop_task) - .await - .expect("keep_advertised_direct should return promptly after stop") - .expect("loop task should not panic"); - println!("OBSERVED: keep_advertised_direct returned promptly after stop"); - - reader_session - .close( - "normal", - Some("keep_advertised_direct reader test done"), - &reader_identity, - ) - .await; - loop_session - .close( - "normal", - Some("keep_advertised_direct loop session done"), - &publisher_identity, - ) - .await; -} From 86e6900b5ad2dd8b65f2c03f57dfd184cfd4329d Mon Sep 17 00:00:00 2001 From: beamologist Date: Sat, 26 Sep 2026 08:15:18 +0200 Subject: [PATCH 04/12] handshake: macula 12's version-4 opener, challenge, CONNECT, HELLO and status frames, client and station halves, held to macula's own frames both ways Co-Authored-By: Claude Opus 5.5 --- src/handshake.rs | 677 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + tests/handshake.rs | 364 ++++++++++++++++++++++++ 3 files changed, 1042 insertions(+) create mode 100644 src/handshake.rs create mode 100644 tests/handshake.rs diff --git a/src/handshake.rs b/src/handshake.rs new file mode 100644 index 0000000..a62a6f9 --- /dev/null +++ b/src/handshake.rs @@ -0,0 +1,677 @@ +//! macula 12's post-quantum connection handshake, as macula_handshake and +//! macula-go build and check it: the opener, challenge, CONNECT, HELLO and +//! status frames (D16, D22), as CBOR bytes without the length prefix. +//! +//! The client opens with an opener. The station answers with a challenge: its +//! carried identity key, its TLS binding and status statement, and a fresh +//! nonce. The client checks the challenge against the node_id it dialed and +//! the leaf it received, before it signs anything, and answers with CONNECT: +//! its identity and CONNECT keys, the CONNECT binding and status statement, +//! and a proof by the CONNECT key. The station checks CONNECT, the puzzle +//! before any signature, and answers with HELLO. Status frames renew a peer's +//! statement on the open connection. +//! +//! Every frame decodes under the decoding rule and must hold exactly the keys +//! of its type, each of its type and length. Close reasons are local: a +//! refusing station sends only a HELLO with one coarse refusal code. + +use std::fmt; + +use sha2::{Digest, Sha384}; + +use crate::binding::{ + verify_connect_binding, verify_status, verify_tls_binding, BindingError, SignedTbs, +}; +use crate::cbor::{self, Value}; +use crate::node_key::{ + carried_key_well_formed, node_id_of, puzzle_solved, signature_size, verify, KeyError, NodeKey, +}; +use crate::profile::Profile; + +/// The handshake's frame version: 4, as macula 12's. A peer on another version +/// hears `unsupported_version`. +pub const VERSION: i64 = 4; + +const NONCE_SIZE: usize = 32; +const MAX_PROTOCOL_INT: i64 = 1 << 53; +const MLDSA_KEY_SIZE: usize = 2592; +const CONNECT_PROOF_LABEL: &[u8] = b"MACULA-PQ-CONNECT-PROOF-V1"; + +const OPENER_KEYS: &[&str] = &["frame_type", "version"]; +const CHALLENGE_KEYS: &[&str] = &[ + "frame_type", + "identity_key", + "nonce", + "profile", + "tls_binding", + "tls_status", + "version", +]; +/// CONNECT always holds member_endorsement, empty when the node has none, as +/// macula 12's: one layout, so the wire does not tell whether a node holds an +/// endorsement or a station asks for one. +const CONNECT_KEYS: &[&str] = &[ + "capabilities", + "connect_binding", + "connect_key", + "connect_status", + "frame_type", + "identity_key", + "member_endorsement", + "proof", + "version", +]; +const HELLO_ACCEPTED_KEYS: &[&str] = &["accepted", "capabilities", "frame_type", "version"]; +const HELLO_REFUSED_KEYS: &[&str] = &[ + "accepted", + "capabilities", + "frame_type", + "refusal_code", + "version", +]; +const STATUS_KEYS: &[&str] = &["frame_type", "statement", "version"]; + +/// The one coarse reason a refusing HELLO carries. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefusalCode { + /// Frames that are not version 4. + UnsupportedVersion, + /// A node_id that misses the puzzle, which the client can check itself. + PuzzleInvalid, + /// A CONNECT that failed any other check. + NotAccepted, +} + +impl RefusalCode { + fn name(self) -> &'static str { + match self { + RefusalCode::UnsupportedVersion => "unsupported_version", + RefusalCode::PuzzleInvalid => "puzzle_invalid", + RefusalCode::NotAccepted => "not_accepted", + } + } + + fn parse(name: &str) -> Option { + match name { + "unsupported_version" => Some(RefusalCode::UnsupportedVersion), + "puzzle_invalid" => Some(RefusalCode::PuzzleInvalid), + "not_accepted" => Some(RefusalCode::NotAccepted), + _ => None, + } + } +} + +/// The handshake's close reasons, named as macula names them. A binding or +/// status statement that fails its check closes with its [`BindingError`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HandshakeError { + /// A frame of another type than the one expected next. + UnexpectedFrame, + /// A frame of another version than 4. + UnsupportedVersion, + /// A frame that does not decode exactly, or a carried key or proof of the + /// wrong form. + Malformed, + /// A challenge that names another profile. + ProfileMismatch, + /// A key that would serve two purposes: a CONNECT key that shares a half + /// with its identity key, or a key found in the leaf. + KeyPurposeReuse, + /// A station whose node_id is not the one dialed. + PeerIdentityMismatch { + expected: [u8; 32], + derived: [u8; 32], + }, + /// A client whose node_id does not meet the puzzle. + PuzzleInvalid, + /// A CONNECT proof that does not verify. + ProofInvalid, + /// A HELLO that refuses the connection, with its code. + Refused(RefusalCode), + /// A station session with a puzzle difficulty the design does not have. + InvalidStationSession, + /// A binding or status statement that did not verify. + Binding(BindingError), + /// A key that could not sign, or randomness that could not be drawn. + Key(KeyError), +} + +impl fmt::Display for HandshakeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + HandshakeError::UnexpectedFrame => f.write_str("unexpected frame"), + HandshakeError::UnsupportedVersion => f.write_str("unsupported frame version"), + HandshakeError::Malformed => f.write_str("malformed frame"), + HandshakeError::ProfileMismatch => f.write_str("the peer names another profile"), + HandshakeError::KeyPurposeReuse => f.write_str("a key would serve two purposes"), + HandshakeError::PeerIdentityMismatch { expected, derived } => write!( + f, + "dialed node_id {}, but the station's key derives {}", + hex_of(expected), + hex_of(derived) + ), + HandshakeError::PuzzleInvalid => f.write_str("the node_id does not meet the puzzle"), + HandshakeError::ProofInvalid => f.write_str("the CONNECT proof does not verify"), + HandshakeError::Refused(code) => { + write!(f, "the station refused the connection: {}", code.name()) + } + HandshakeError::InvalidStationSession => { + f.write_str("the station session has an unknown puzzle difficulty") + } + HandshakeError::Binding(e) => write!(f, "{e}"), + HandshakeError::Key(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for HandshakeError {} + +impl From for HandshakeError { + fn from(e: BindingError) -> Self { + HandshakeError::Binding(e) + } +} + +/// How a station treats a client's node_id puzzle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PuzzleMode { + /// The puzzle is not checked. + Off, + /// An unsolved puzzle is accepted and reported. + LogOnly, + /// An unsolved puzzle is refused. + Enforce, +} + +/// What a station found of a client's puzzle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PuzzleResult { + Solved, + Unsolved, + NotChecked, +} + +/// The client's first frame on the control stream. It carries nothing that +/// relates to identity. +pub fn opener() -> Vec { + encode_frame("opener", Vec::new()) +} + +/// The station's check of the first frame. +pub fn read_opener(frame: &[u8]) -> Result<(), HandshakeError> { + decode(frame, "opener", &[OPENER_KEYS]).map(|_| ()) +} + +/// What a station precomputes for its challenges: its carried identity key, +/// and the TLS binding and status statement for the leaf it presents. +#[derive(Debug, Clone)] +pub struct StationMaterial { + pub profile: Profile, + pub identity_key: Vec, + pub tls_binding: SignedTbs, + pub tls_status: SignedTbs, +} + +/// A station's challenge, with a fresh nonce. The station keeps the bytes it +/// sends, for the proof check. +pub fn challenge(m: &StationMaterial) -> Result, HandshakeError> { + let mut nonce = [0u8; NONCE_SIZE]; + aws_lc_rs::rand::fill(&mut nonce) + .map_err(|_| HandshakeError::Key(KeyError::RandomnessUnavailable))?; + Ok(encode_frame( + "challenge", + vec![ + entry("nonce", Value::Bytes(nonce.to_vec())), + entry("profile", Value::text(m.profile.name())), + entry("identity_key", Value::Bytes(m.identity_key.clone())), + entry("tls_binding", m.tls_binding.to_value()), + entry("tls_status", m.tls_status.to_value()), + ], + )) +} + +/// What a client brings to a handshake: its profile, the node_id it dialed, +/// the leaf DER it received in this TLS handshake, its carried identity key, +/// its CONNECT key with binding and status statement, its capability bits, +/// the time in milliseconds, and the realm membership endorsement CONNECT +/// carries, empty for a node that holds none. +pub struct ClientSession<'a> { + pub profile: Profile, + pub expected_node_id: [u8; 32], + pub leaf: &'a [u8], + pub identity_key: Vec, + pub connect_key: &'a NodeKey, + pub connect_binding: &'a SignedTbs, + pub connect_status: &'a SignedTbs, + pub capabilities: u64, + pub now_ms: i64, + pub member_endorsement: Vec, +} + +/// What a client knows of the station once it has checked the challenge. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Station { + pub node_id: [u8; 32], + pub identity_key: Vec, + pub tls_binding: SignedTbs, + pub status_expires_at: i64, + pub binding_not_after: i64, +} + +/// Checks a challenge and, when every check passes, returns the CONNECT to +/// send. It checks, in macula's order: the frame, the profile, the station's +/// carried key, that each key in view serves one purpose, the station's +/// node_id against the one dialed, the TLS binding against the leaf received, +/// and the status statement. It signs nothing before all of them pass. +pub fn answer_challenge( + challenge: &[u8], + s: &ClientSession<'_>, +) -> Result<(Vec, Station), HandshakeError> { + let f = decode(challenge, "challenge", &[CHALLENGE_KEYS])?; + let station_key = f.bytes("identity_key"); + let connect_key = s.connect_key.public_key(); + if f.text("profile") != s.profile.name() { + return Err(HandshakeError::ProfileMismatch); + } + if !carried_key_well_formed(station_key, s.profile) { + return Err(HandshakeError::Malformed); + } + if shares_a_half(&s.identity_key, &connect_key) + || in_leaf(station_key, s.leaf) + || in_leaf(&connect_key, s.leaf) + { + return Err(HandshakeError::KeyPurposeReuse); + } + let station_node_id = node_id_of(station_key, s.profile); + if station_node_id != s.expected_node_id { + return Err(HandshakeError::PeerIdentityMismatch { + expected: s.expected_node_id, + derived: station_node_id, + }); + } + let tls_binding = f.signed("tls_binding"); + let binding = verify_tls_binding(&tls_binding, station_key, s.profile, s.leaf, s.now_ms)?; + let expires_at = verify_status( + &f.signed("tls_status"), + &tls_binding, + station_key, + s.profile, + s.now_ms, + )?; + let client_node_id = node_id_of(&s.identity_key, s.profile); + let proof = s + .connect_key + .sign(&proof_message( + f.bytes("nonce"), + &station_node_id, + &client_node_id, + s.leaf, + challenge, + )) + .map_err(HandshakeError::Key)?; + let connect = encode_frame( + "connect", + vec![ + entry("identity_key", Value::Bytes(s.identity_key.clone())), + entry("connect_key", Value::Bytes(connect_key)), + entry("connect_binding", s.connect_binding.to_value()), + entry("connect_status", s.connect_status.to_value()), + entry("proof", Value::Bytes(proof)), + entry("capabilities", Value::Int(i128::from(s.capabilities))), + entry( + "member_endorsement", + Value::Bytes(s.member_endorsement.clone()), + ), + ], + ); + Ok(( + connect, + Station { + node_id: station_node_id, + identity_key: station_key.to_vec(), + tls_binding, + status_expires_at: expires_at, + binding_not_after: binding.not_after, + }, + )) +} + +/// What a station brings to a CONNECT check: its profile, the challenge bytes +/// it sent, the leaf DER this connection presented, its puzzle difficulty and +/// mode, its capability bits, and the time in milliseconds. +#[derive(Debug, Clone)] +pub struct StationSession { + pub profile: Profile, + pub challenge: Vec, + pub leaf: Vec, + pub puzzle_difficulty: u32, + pub puzzle_mode: PuzzleMode, + pub capabilities: u64, + pub now_ms: i64, +} + +/// What a station knows of an accepted client. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Client { + pub node_id: [u8; 32], + pub identity_key: Vec, + pub connect_key: Vec, + pub connect_binding: SignedTbs, + pub capabilities: u64, + pub status_expires_at: i64, + pub binding_not_after: i64, + pub puzzle: PuzzleResult, + /// The endorsement the CONNECT carried, empty when the client holds none. + /// Nothing here checks it: that is the station's policy. + pub member_endorsement: Vec, +} + +/// Checks a CONNECT, and returns the verdict with the HELLO to send. It +/// checks, in macula's order: the frame, the carried keys and the proof's +/// length, that each key serves one purpose, the puzzle on the derived node_id +/// before any signature, the CONNECT binding and status statement, and the +/// proof against the challenge this station sent and the leaf it presented. A +/// refusal is the local close reason, and the HELLO refuses with one coarse +/// code. +pub fn accept_connect( + connect: &[u8], + s: &StationSession, +) -> (Result, Vec) { + match check_connect(connect, s) { + Ok(client) => (Ok(client), hello(None, s.capabilities)), + Err(e) => { + let code = match e { + HandshakeError::UnsupportedVersion => RefusalCode::UnsupportedVersion, + HandshakeError::PuzzleInvalid => RefusalCode::PuzzleInvalid, + _ => RefusalCode::NotAccepted, + }; + (Err(e), hello(Some(code), s.capabilities)) + } + } +} + +fn check_connect(connect: &[u8], s: &StationSession) -> Result { + if s.puzzle_difficulty > 256 { + return Err(HandshakeError::InvalidStationSession); + } + let f = decode(connect, "connect", &[CONNECT_KEYS])?; + let (identity_key, connect_key, proof) = ( + f.bytes("identity_key"), + f.bytes("connect_key"), + f.bytes("proof"), + ); + if !carried_key_well_formed(identity_key, s.profile) + || !carried_key_well_formed(connect_key, s.profile) + || proof.len() != signature_size(s.profile) + { + return Err(HandshakeError::Malformed); + } + if shares_a_half(identity_key, connect_key) || in_leaf(connect_key, &s.leaf) { + return Err(HandshakeError::KeyPurposeReuse); + } + let node_id = node_id_of(identity_key, s.profile); + let puzzle = match s.puzzle_mode { + PuzzleMode::Off => PuzzleResult::NotChecked, + _ if puzzle_solved(&node_id, s.puzzle_difficulty) => PuzzleResult::Solved, + _ => PuzzleResult::Unsolved, + }; + if puzzle == PuzzleResult::Unsolved && s.puzzle_mode == PuzzleMode::Enforce { + return Err(HandshakeError::PuzzleInvalid); + } + let connect_binding = f.signed("connect_binding"); + let binding = verify_connect_binding( + &connect_binding, + identity_key, + s.profile, + connect_key, + s.now_ms, + )?; + let expires_at = verify_status( + &f.signed("connect_status"), + &connect_binding, + identity_key, + s.profile, + s.now_ms, + )?; + if !proof_verifies(s, &node_id, connect_key, proof) { + return Err(HandshakeError::ProofInvalid); + } + Ok(Client { + node_id, + identity_key: identity_key.to_vec(), + connect_key: connect_key.to_vec(), + connect_binding, + capabilities: f.uint("capabilities"), + status_expires_at: expires_at, + binding_not_after: binding.not_after, + puzzle, + member_endorsement: f.bytes("member_endorsement").to_vec(), + }) +} + +/// A CONNECT proof checked against the challenge the station sent and the +/// leaf it presented. The station's own challenge decodes: it built it. +fn proof_verifies( + s: &StationSession, + client_node_id: &[u8; 32], + connect_key: &[u8], + proof: &[u8], +) -> bool { + let Ok(challenge) = decode(&s.challenge, "challenge", &[CHALLENGE_KEYS]) else { + return false; + }; + let station_node_id = node_id_of(challenge.bytes("identity_key"), s.profile); + let message = proof_message( + challenge.bytes("nonce"), + &station_node_id, + client_node_id, + &s.leaf, + &s.challenge, + ); + verify(&message, proof, connect_key, s.profile) +} + +/// What the CONNECT proof signs: the label, a zero byte, the nonce, the +/// station's and the client's node_ids, the SHA-384 of the leaf DER and the +/// SHA-384 of the challenge bytes as received. +fn proof_message( + nonce: &[u8], + station_node_id: &[u8; 32], + client_node_id: &[u8; 32], + leaf: &[u8], + challenge: &[u8], +) -> Vec { + let mut out = Vec::with_capacity(CONNECT_PROOF_LABEL.len() + 1 + nonce.len() + 64 + 96); + out.extend_from_slice(CONNECT_PROOF_LABEL); + out.push(0); + out.extend_from_slice(nonce); + out.extend_from_slice(station_node_id); + out.extend_from_slice(client_node_id); + out.extend_from_slice(&Sha384::digest(leaf)); + out.extend_from_slice(&Sha384::digest(challenge)); + out +} + +fn hello(refusal: Option, capabilities: u64) -> Vec { + let mut entries = vec![ + entry("accepted", Value::Int(i128::from(refusal.is_none()))), + entry("capabilities", Value::Int(i128::from(capabilities))), + ]; + if let Some(code) = refusal { + entries.push(entry("refusal_code", Value::text(code.name()))); + } + encode_frame("hello", entries) +} + +/// The client's reading of HELLO: the station's capability bits, or +/// [`HandshakeError::Refused`] with its refusal code. +pub fn read_hello(frame: &[u8]) -> Result { + let f = decode(frame, "hello", &[HELLO_ACCEPTED_KEYS, HELLO_REFUSED_KEYS])?; + let accepted = f.int("accepted"); + match (accepted, f.0.get("refusal_code")) { + (1, None) => Ok(f.uint("capabilities")), + (0, Some(Value::Text(code))) => Err(HandshakeError::Refused( + RefusalCode::parse(code).ok_or(HandshakeError::Malformed)?, + )), + _ => Err(HandshakeError::Malformed), + } +} + +/// A status frame carrying a fresh status statement, sent at every reissue. +pub fn status_frame(statement: &SignedTbs) -> Vec { + encode_frame("status", vec![entry("statement", statement.to_value())]) +} + +/// What a connection checks a peer's status frames against: the profile, the +/// identity key and binding the handshake verified, and the time in +/// milliseconds. +#[derive(Debug, Clone)] +pub struct Peer { + pub profile: Profile, + pub identity_key: Vec, + pub binding: SignedTbs, + pub now_ms: i64, +} + +/// Checks a peer's status frame, and returns when its statement expires. +pub fn read_status(frame: &[u8], p: &Peer) -> Result { + let f = decode(frame, "status", &[STATUS_KEYS])?; + Ok(verify_status( + &f.signed("statement"), + &p.binding, + &p.identity_key, + p.profile, + p.now_ms, + )?) +} + +/// A decoded handshake frame's values by their keys. +struct Fields(std::collections::HashMap); + +impl Fields { + fn bytes(&self, key: &str) -> &[u8] { + match self.0.get(key) { + Some(Value::Bytes(b)) => b, + _ => &[], + } + } + + fn text(&self, key: &str) -> &str { + match self.0.get(key) { + Some(Value::Text(t)) => t, + _ => "", + } + } + + fn int(&self, key: &str) -> i128 { + match self.0.get(key) { + Some(Value::Int(n)) => *n, + _ => -1, + } + } + + fn uint(&self, key: &str) -> u64 { + u64::try_from(self.int(key)).unwrap_or(0) + } + + fn signed(&self, key: &str) -> SignedTbs { + self.0 + .get(key) + .and_then(|v| SignedTbs::from_value(v).ok()) + .unwrap_or(SignedTbs { + tbs: Vec::new(), + signature: Vec::new(), + }) + } +} + +/// A handshake frame read strictly, in macula's order: the decoding rule, the +/// version, the frame type, exactly the keys of one of the layouts, then the +/// type and length of every field. +fn decode(frame: &[u8], frame_type: &str, layouts: &[&[&str]]) -> Result { + let Ok(Value::Map(pairs)) = cbor::decode(frame) else { + return Err(HandshakeError::Malformed); + }; + let mut fields = std::collections::HashMap::with_capacity(pairs.len()); + let mut non_text = 0; + for (key, value) in pairs { + match key { + Value::Text(name) => { + fields.insert(name, value); + } + _ => non_text += 1, + } + } + match fields.get("version") { + Some(Value::Int(v)) if *v == i128::from(VERSION) => {} + Some(Value::Int(_)) => return Err(HandshakeError::UnsupportedVersion), + _ => return Err(HandshakeError::Malformed), + } + match fields.get("frame_type") { + Some(Value::Text(t)) if t == frame_type => {} + Some(Value::Text(_)) => return Err(HandshakeError::UnexpectedFrame), + _ => return Err(HandshakeError::Malformed), + } + let mut keys: Vec<&str> = fields.keys().map(String::as_str).collect(); + keys.sort_unstable(); + let has_layout = layouts.iter().any(|layout| *layout == keys.as_slice()); + if non_text > 0 || !has_layout || !fields.iter().all(|(k, v)| field_typed(k, v)) { + return Err(HandshakeError::Malformed); + } + Ok(Fields(fields)) +} + +fn field_typed(key: &str, v: &Value) -> bool { + match key { + "version" | "frame_type" => true, + "profile" => matches!(v, Value::Text(_)), + "nonce" => matches!(v, Value::Bytes(b) if b.len() == NONCE_SIZE), + "identity_key" | "connect_key" | "proof" | "member_endorsement" => { + matches!(v, Value::Bytes(_)) + } + "tls_binding" | "tls_status" | "connect_binding" | "connect_status" | "statement" => { + SignedTbs::from_value(v).is_ok() + } + "capabilities" => { + matches!(v, Value::Int(n) if *n >= 0 && *n < i128::from(MAX_PROTOCOL_INT)) + } + "accepted" => matches!(v, Value::Int(0 | 1)), + "refusal_code" => matches!(v, Value::Text(t) if RefusalCode::parse(t).is_some()), + _ => false, + } +} + +/// Whether `leaf` holds `key`'s ML-DSA-87 half. +fn in_leaf(key: &[u8], leaf: &[u8]) -> bool { + key.len() >= MLDSA_KEY_SIZE + && leaf + .windows(MLDSA_KEY_SIZE) + .any(|w| w == &key[..MLDSA_KEY_SIZE]) +} + +/// Whether two carried keys share their ML-DSA-87 half, or a classical half. +fn shares_a_half(a: &[u8], b: &[u8]) -> bool { + if a.len() < MLDSA_KEY_SIZE || b.len() < MLDSA_KEY_SIZE { + return a == b; + } + let (classical_a, classical_b) = (&a[MLDSA_KEY_SIZE..], &b[MLDSA_KEY_SIZE..]); + a[..MLDSA_KEY_SIZE] == b[..MLDSA_KEY_SIZE] + || (!classical_a.is_empty() && classical_a == classical_b) +} + +fn encode_frame(frame_type: &str, entries: Vec<(Value, Value)>) -> Vec { + let mut all = vec![ + entry("version", Value::Int(i128::from(VERSION))), + entry("frame_type", Value::text(frame_type)), + ]; + all.extend(entries); + cbor::encode(&Value::Map(all)).expect("a handshake frame's integers are all below 2^53") +} + +fn entry(key: &str, value: Value) -> (Value, Value) { + (Value::text(key), value) +} + +fn hex_of(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} diff --git a/src/lib.rs b/src/lib.rs index e939fdd..c13a0ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod binding; pub mod cbor; +pub mod handshake; pub mod keystore; pub mod node_key; pub mod petname; diff --git a/tests/handshake.rs b/tests/handshake.rs new file mode 100644 index 0000000..367f8c7 --- /dev/null +++ b/tests/handshake.rs @@ -0,0 +1,364 @@ +//! macula 12's version-4 handshake: opener, challenge, CONNECT, HELLO and +//! status frames. Held to the frames macula itself made +//! (tests/vectors/handshake/erlang_handshake.json, macula_handshake at macula +//! v12.1.0) both ways, then driven end to end between this crate's client and +//! station halves, and refused where macula refuses. + +use macula_rust::binding::{ + connect_binding, status_statement, tls_binding, BindingError, SignedTbs, +}; +use macula_rust::cbor::{self, Value}; +use macula_rust::handshake::{ + accept_connect, answer_challenge, challenge, opener, read_hello, read_opener, read_status, + status_frame, ClientSession, HandshakeError, Peer, PuzzleMode, PuzzleResult, RefusalCode, + StationMaterial, StationSession, +}; +use macula_rust::node_key::{NodeKey, Purpose}; +use macula_rust::profile::Profile; + +const HOUR_MS: i64 = 60 * 60 * 1000; +const DAY_MS: i64 = 24 * HOUR_MS; + +fn unhex(s: &str) -> Vec { + hex::decode(s).unwrap() +} + +/// A client's identity key, CONNECT key, binding and status statement. +struct ClientKeys { + identity: NodeKey, + connect: NodeKey, + binding: SignedTbs, + status: SignedTbs, +} + +fn client_keys(profile: Profile, now: i64) -> ClientKeys { + let identity = NodeKey::generate_identity(profile, 0).unwrap(); + let connect = NodeKey::generate(Purpose::Connect, profile).unwrap(); + let binding = connect_binding(&identity, &connect.public_key(), now, now + DAY_MS).unwrap(); + let status = status_statement(&identity, &binding, now, now + HOUR_MS).unwrap(); + ClientKeys { + identity, + connect, + binding, + status, + } +} + +fn session<'a>( + keys: &'a ClientKeys, + profile: Profile, + expected: [u8; 32], + leaf: &'a [u8], + now: i64, +) -> ClientSession<'a> { + ClientSession { + profile, + expected_node_id: expected, + leaf, + identity_key: keys.identity.public_key(), + connect_key: &keys.connect, + connect_binding: &keys.binding, + connect_status: &keys.status, + capabilities: 3, + now_ms: now, + member_endorsement: Vec::new(), + } +} + +/// A station: its identity key, and the material it challenges with for +/// `leaf`. +fn station(profile: Profile, leaf: &[u8], now: i64) -> (NodeKey, StationMaterial) { + let identity = NodeKey::generate_identity(profile, 0).unwrap(); + let binding = tls_binding(&identity, leaf, now, now + DAY_MS).unwrap(); + let status = status_statement(&identity, &binding, now, now + HOUR_MS).unwrap(); + let material = StationMaterial { + profile, + identity_key: identity.public_key(), + tls_binding: binding, + tls_status: status, + }; + (identity, material) +} + +fn station_session(profile: Profile, challenge: &[u8], leaf: &[u8], now: i64) -> StationSession { + StationSession { + profile, + challenge: challenge.to_vec(), + leaf: leaf.to_vec(), + puzzle_difficulty: 0, + puzzle_mode: PuzzleMode::Enforce, + capabilities: 5, + now_ms: now, + } +} + +struct Erlang { + now: i64, + leaf: Vec, + entries: Vec<(Profile, Vec, [u8; 32], Vec, Vec)>, +} + +fn erlang() -> Erlang { + let text = std::fs::read_to_string("tests/vectors/handshake/erlang_handshake.json").unwrap(); + let doc: serde_json::Value = serde_json::from_str(&text).unwrap(); + let s = |v: &serde_json::Value| v.as_str().unwrap().to_owned(); + let entries: Vec<_> = doc["entries"] + .as_array() + .unwrap() + .iter() + .map(|e| { + ( + Profile::parse(&s(&e["profile"])).unwrap(), + unhex(&s(&e["erlang_challenge"])), + unhex(&s(&e["erlang_station_node_id"])).try_into().unwrap(), + unhex(&s(&e["go_challenge"])), + unhex(&s(&e["erlang_connect"])), + ) + }) + .collect(); + assert_eq!(entries.len(), 2); + Erlang { + now: doc["now"].as_i64().unwrap(), + leaf: unhex(&s(&doc["leaf"])), + entries, + } +} + +#[test] +fn a_challenge_macula_made_is_answered() { + let h = erlang(); + for (profile, erlang_challenge, station_node_id, _, _) in &h.entries { + let keys = client_keys(*profile, h.now); + let (connect, station) = answer_challenge( + erlang_challenge, + &session(&keys, *profile, *station_node_id, &h.leaf, h.now + 60_000), + ) + .unwrap(); + assert_eq!(&station.node_id, station_node_id, "{profile:?}"); + assert!(!connect.is_empty()); + } +} + +#[test] +fn a_connect_macula_made_is_accepted() { + let h = erlang(); + for (profile, _, _, go_challenge, erlang_connect) in &h.entries { + let (accepted, hello) = accept_connect( + erlang_connect, + &station_session(*profile, go_challenge, &h.leaf, h.now + 60_000), + ); + let client = accepted.unwrap(); + assert_eq!(read_hello(&hello).unwrap(), 5, "{profile:?}"); + assert!(client.member_endorsement.is_empty()); + } +} + +#[test] +fn a_client_and_a_station_complete_the_handshake_and_renew_status() { + let now = 1_789_000_000_000; + let leaf = b"the leaf this connection presents".to_vec(); + for profile in [Profile::PqPure, Profile::PqHybrid] { + let first = opener(); + read_opener(&first).unwrap(); + let (station_key, material) = station(profile, &leaf, now); + let challenge_frame = challenge(&material).unwrap(); + let keys = client_keys(profile, now); + let (connect, seen) = answer_challenge( + &challenge_frame, + &session(&keys, profile, station_key.node_id().unwrap(), &leaf, now), + ) + .unwrap(); + assert_eq!(seen.status_expires_at, now + HOUR_MS); + let (accepted, hello) = accept_connect( + &connect, + &station_session(profile, &challenge_frame, &leaf, now), + ); + let client = accepted.unwrap(); + assert_eq!(client.node_id, keys.identity.node_id().unwrap()); + assert_eq!(client.capabilities, 3); + assert_eq!(client.puzzle, PuzzleResult::Solved); + assert_eq!(read_hello(&hello).unwrap(), 5); + + // A renewed statement on the open connection. + let renewed = status_statement( + &station_key, + &material.tls_binding, + now + 900_000, + now + 900_000 + HOUR_MS, + ) + .unwrap(); + let peer = Peer { + profile, + identity_key: material.identity_key.clone(), + binding: material.tls_binding.clone(), + now_ms: now + 900_000, + }; + assert_eq!( + read_status(&status_frame(&renewed), &peer).unwrap(), + now + 900_000 + HOUR_MS + ); + } +} + +#[test] +fn a_station_that_is_not_the_node_dialed_is_refused_before_anything_is_signed() { + let now = 1_789_000_000_000; + let leaf = b"leaf".to_vec(); + let (station_key, material) = station(Profile::PqPure, &leaf, now); + let keys = client_keys(Profile::PqPure, now); + let dialed = [9u8; 32]; + match answer_challenge( + &challenge(&material).unwrap(), + &session(&keys, Profile::PqPure, dialed, &leaf, now), + ) { + Err(HandshakeError::PeerIdentityMismatch { expected, derived }) => { + assert_eq!(expected, dialed); + assert_eq!(derived, station_key.node_id().unwrap()); + } + other => panic!("{:?}", other.map(|(c, _)| c.len())), + } +} + +#[test] +fn a_challenge_for_another_leaf_or_profile_is_refused() { + let now = 1_789_000_000_000; + let (station_key, material) = station(Profile::PqPure, b"the real leaf", now); + let frame = challenge(&material).unwrap(); + let keys = client_keys(Profile::PqPure, now); + let expected = station_key.node_id().unwrap(); + assert_eq!( + answer_challenge( + &frame, + &session(&keys, Profile::PqPure, expected, b"another leaf", now) + ) + .unwrap_err(), + HandshakeError::Binding(BindingError::KeyMismatch) + ); + let hybrid = client_keys(Profile::PqHybrid, now); + assert_eq!( + answer_challenge( + &frame, + &session(&hybrid, Profile::PqHybrid, expected, b"the real leaf", now) + ) + .unwrap_err(), + HandshakeError::ProfileMismatch + ); +} + +#[test] +fn a_connect_key_that_is_the_identity_key_is_refused() { + let now = 1_789_000_000_000; + let leaf = b"leaf".to_vec(); + let (station_key, material) = station(Profile::PqPure, &leaf, now); + let identity = NodeKey::generate_identity(Profile::PqPure, 0).unwrap(); + // The identity key's own halves, as a CONNECT key: its key file with the + // purpose byte after the magic changed to connect. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("key"); + identity.save(&path).unwrap(); + let mut file = std::fs::read(&path).unwrap(); + file[b"macula-node-key-seed-v1\0".len()] = 2; + std::fs::write(&path, &file).unwrap(); + let same = NodeKey::load(&path, Purpose::Connect, Profile::PqPure).unwrap(); + let binding = connect_binding(&identity, &same.public_key(), now, now + DAY_MS).unwrap(); + let status = status_statement(&identity, &binding, now, now + HOUR_MS).unwrap(); + let keys = ClientKeys { + identity, + connect: same, + binding, + status, + }; + assert_eq!( + answer_challenge( + &challenge(&material).unwrap(), + &session( + &keys, + Profile::PqPure, + station_key.node_id().unwrap(), + &leaf, + now + ) + ) + .unwrap_err(), + HandshakeError::KeyPurposeReuse + ); +} + +#[test] +fn a_station_refuses_with_one_coarse_code() { + let now = 1_789_000_000_000; + let leaf = b"leaf".to_vec(); + let (station_key, material) = station(Profile::PqPure, &leaf, now); + let frame = challenge(&material).unwrap(); + let keys = client_keys(Profile::PqPure, now); + let (connect, _) = answer_challenge( + &frame, + &session( + &keys, + Profile::PqPure, + station_key.node_id().unwrap(), + &leaf, + now, + ), + ) + .unwrap(); + + // The proof covers the leaf: presented another one, the station refuses. + let (refused, hello) = accept_connect( + &connect, + &station_session(Profile::PqPure, &frame, b"other", now), + ); + assert_eq!(refused.unwrap_err(), HandshakeError::ProofInvalid); + assert_eq!( + read_hello(&hello).unwrap_err(), + HandshakeError::Refused(RefusalCode::NotAccepted) + ); + + // A node_id that misses an enforced puzzle. + let mut hard = station_session(Profile::PqPure, &frame, &leaf, now); + hard.puzzle_difficulty = 256; + let (refused, hello) = accept_connect(&connect, &hard); + assert_eq!(refused.unwrap_err(), HandshakeError::PuzzleInvalid); + assert_eq!( + read_hello(&hello).unwrap_err(), + HandshakeError::Refused(RefusalCode::PuzzleInvalid) + ); + + // Logged, not enforced: accepted, and reported unsolved. + hard.puzzle_mode = PuzzleMode::LogOnly; + let (accepted, _) = accept_connect(&connect, &hard); + assert_eq!(accepted.unwrap().puzzle, PuzzleResult::Unsolved); +} + +/// A frame rewritten: `f` edits its decoded map, and the result is +/// re-encoded. +fn rewritten(frame: &[u8], f: impl FnOnce(&mut Vec<(Value, Value)>)) -> Vec { + let Value::Map(mut pairs) = cbor::decode(frame).unwrap() else { + panic!("a frame is a map") + }; + f(&mut pairs); + cbor::encode(&Value::Map(pairs)).unwrap() +} + +#[test] +fn a_frame_is_read_strictly_in_macula_s_order() { + let first = opener(); + let v3 = rewritten(&first, |p| { + for (k, v) in p.iter_mut() { + if *k == Value::text("version") { + *v = Value::Int(3); + } + } + }); + assert_eq!( + read_opener(&v3).unwrap_err(), + HandshakeError::UnsupportedVersion + ); + let extra = rewritten(&first, |p| p.push((Value::text("more"), Value::Int(1)))); + assert_eq!(read_opener(&extra).unwrap_err(), HandshakeError::Malformed); + assert_eq!( + read_hello(&first).unwrap_err(), + HandshakeError::UnexpectedFrame + ); + assert_eq!(read_opener(b"\xff").unwrap_err(), HandshakeError::Malformed); +} From bfc611aacffd2897a9e25a1394b6dd6bd9b52181 Mon Sep 17 00:00:00 2001 From: beamologist Date: Sat, 26 Sep 2026 08:16:32 +0200 Subject: [PATCH 05/12] handshake: satisfy clippy (a layout lookup, and a struct for macula's frames in the tests) Co-Authored-By: Claude Opus 5.5 --- src/handshake.rs | 2 +- tests/handshake.rs | 44 ++++++++++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/handshake.rs b/src/handshake.rs index a62a6f9..9723f9e 100644 --- a/src/handshake.rs +++ b/src/handshake.rs @@ -614,7 +614,7 @@ fn decode(frame: &[u8], frame_type: &str, layouts: &[&[&str]]) -> Result = fields.keys().map(String::as_str).collect(); keys.sort_unstable(); - let has_layout = layouts.iter().any(|layout| *layout == keys.as_slice()); + let has_layout = layouts.contains(&keys.as_slice()); if non_text > 0 || !has_layout || !fields.iter().all(|(k, v)| field_typed(k, v)) { return Err(HandshakeError::Malformed); } diff --git a/tests/handshake.rs b/tests/handshake.rs index 367f8c7..ae7c41a 100644 --- a/tests/handshake.rs +++ b/tests/handshake.rs @@ -92,10 +92,20 @@ fn station_session(profile: Profile, challenge: &[u8], leaf: &[u8], now: i64) -> } } +/// One profile's frames: a challenge macula made as a station, its node_id, +/// and a CONNECT macula made answering a challenge macula-go made. +struct ErlangEntry { + profile: Profile, + erlang_challenge: Vec, + station_node_id: [u8; 32], + go_challenge: Vec, + erlang_connect: Vec, +} + struct Erlang { now: i64, leaf: Vec, - entries: Vec<(Profile, Vec, [u8; 32], Vec, Vec)>, + entries: Vec, } fn erlang() -> Erlang { @@ -106,14 +116,12 @@ fn erlang() -> Erlang { .as_array() .unwrap() .iter() - .map(|e| { - ( - Profile::parse(&s(&e["profile"])).unwrap(), - unhex(&s(&e["erlang_challenge"])), - unhex(&s(&e["erlang_station_node_id"])).try_into().unwrap(), - unhex(&s(&e["go_challenge"])), - unhex(&s(&e["erlang_connect"])), - ) + .map(|e| ErlangEntry { + profile: Profile::parse(&s(&e["profile"])).unwrap(), + erlang_challenge: unhex(&s(&e["erlang_challenge"])), + station_node_id: unhex(&s(&e["erlang_station_node_id"])).try_into().unwrap(), + go_challenge: unhex(&s(&e["go_challenge"])), + erlang_connect: unhex(&s(&e["erlang_connect"])), }) .collect(); assert_eq!(entries.len(), 2); @@ -127,14 +135,14 @@ fn erlang() -> Erlang { #[test] fn a_challenge_macula_made_is_answered() { let h = erlang(); - for (profile, erlang_challenge, station_node_id, _, _) in &h.entries { - let keys = client_keys(*profile, h.now); + for e in &h.entries { + let keys = client_keys(e.profile, h.now); let (connect, station) = answer_challenge( - erlang_challenge, - &session(&keys, *profile, *station_node_id, &h.leaf, h.now + 60_000), + &e.erlang_challenge, + &session(&keys, e.profile, e.station_node_id, &h.leaf, h.now + 60_000), ) .unwrap(); - assert_eq!(&station.node_id, station_node_id, "{profile:?}"); + assert_eq!(station.node_id, e.station_node_id, "{:?}", e.profile); assert!(!connect.is_empty()); } } @@ -142,13 +150,13 @@ fn a_challenge_macula_made_is_answered() { #[test] fn a_connect_macula_made_is_accepted() { let h = erlang(); - for (profile, _, _, go_challenge, erlang_connect) in &h.entries { + for e in &h.entries { let (accepted, hello) = accept_connect( - erlang_connect, - &station_session(*profile, go_challenge, &h.leaf, h.now + 60_000), + &e.erlang_connect, + &station_session(e.profile, &e.go_challenge, &h.leaf, h.now + 60_000), ); let client = accepted.unwrap(); - assert_eq!(read_hello(&hello).unwrap(), 5, "{profile:?}"); + assert_eq!(read_hello(&hello).unwrap(), 5, "{:?}", e.profile); assert!(client.member_endorsement.is_empty()); } } From ca8b3c05f788ffcbb6bdc0b0e8e001a1211062ee Mon Sep 17 00:00:00 2001 From: beamologist Date: Sat, 26 Sep 2026 08:25:42 +0200 Subject: [PATCH 06/12] frame: macula 12's signed frames (requests, replies, relay errors, publications, stream frames), neighbour signatures, the codec and payload rules Held to macula's own neighbour-signed control frames, both profiles, and to the shared vectors that read a CALL's fields. Co-Authored-By: Claude Opus 5.5 --- src/frame.rs | 419 +++++++++++++++++++++++ src/frame/check_payload.rs | 146 ++++++++ src/frame/neighbour.rs | 298 ++++++++++++++++ src/frame/publication.rs | 173 ++++++++++ src/frame/reply.rs | 393 +++++++++++++++++++++ src/frame/request.rs | 290 ++++++++++++++++ src/frame/stream.rs | 494 +++++++++++++++++++++++++++ src/lib.rs | 1 + tests/frame_codec.rs | 104 ++++++ tests/frame_publication_neighbour.rs | 240 +++++++++++++ tests/frame_request_reply.rs | 335 ++++++++++++++++++ tests/frame_stream.rs | 202 +++++++++++ 12 files changed, 3095 insertions(+) create mode 100644 src/frame.rs create mode 100644 src/frame/check_payload.rs create mode 100644 src/frame/neighbour.rs create mode 100644 src/frame/publication.rs create mode 100644 src/frame/reply.rs create mode 100644 src/frame/request.rs create mode 100644 src/frame/stream.rs create mode 100644 tests/frame_codec.rs create mode 100644 tests/frame_publication_neighbour.rs create mode 100644 tests/frame_request_reply.rs create mode 100644 tests/frame_stream.rs diff --git a/src/frame.rs b/src/frame.rs new file mode 100644 index 0000000..c3136a8 --- /dev/null +++ b/src/frame.rs @@ -0,0 +1,419 @@ +//! macula 12's frames, as macula_frame and macula-go build and read them: the +//! requests, replies, relay errors, publications and stream frames that carry +//! signed objects, the control frames a pq_hybrid link neighbour-signs, the +//! decoding rule's payload bounds, and the length-prefixed wire codec. +//! +//! A wire frame is ``, the deterministic +//! encoding of one map with `version` and `frame_type`. No frame carries a +//! frame-level signature: what is signed is the signed object a frame holds, +//! and, in pq_hybrid, a control frame's neighbour signature. + +mod check_payload; +mod neighbour; +mod publication; +mod reply; +mod request; +mod stream; + +pub use check_payload::{ + check_frame, check_payload, FRAME_RESERVED_ELEMENTS, MAX_PAYLOAD_ELEMENTS, MAX_PAYLOAD_NESTING, +}; +pub use neighbour::{ + advertise_frame, goodbye_frame, neighbour_signed, sign_neighbour, subscribe_frame, + unadvertise_frame, unsubscribe_frame, verify_neighbour, NeighbourLink, NeighbourPeer, +}; +pub use publication::{sign_publish, verify_publication, PublicationSpec, VerifiedPublication}; +pub use reply::{ + claimed_reply_ids, sign_provider_error, sign_relay_error, sign_result, verify_relay_error, + verify_reply, RelayErrorSpec, RelayErrorType, ReplyType, VerifiedRelayError, VerifiedReply, +}; +pub use request::{ + request_fields_accepted, sign_call, sign_stream_open, verify_request, RequestSpec, RequestType, + VerifiedRequest, MAX_PROOFS, MAX_PROOFS_BYTES, +}; +pub use stream::{ + open_stream, sign_caller_stream, sign_provider_stream, verify_caller_stream, + verify_provider_stream, StreamEncoding, StreamFields, StreamMode, StreamRole, StreamState, + VerifiedStreamFrame, +}; + +use std::fmt; + +use crate::cbor::{self, Value}; +use crate::node_key::{KeyError, NodeKey, Purpose}; +use crate::signed_object::ObjectError; + +/// The version field every frame carries. +pub const PROTOCOL_VERSION: i64 = 2; + +/// The CBOR payload size cap: 16 MiB minus one byte, as macula's. +pub const MAX_FRAME_BYTES: usize = 0x00FF_FFFF; + +/// The labels of the signed objects frames carry (D25, D17). +const REQUEST_LABEL: &str = "MACULA-PQ-REQUEST-V1"; +const REPLY_LABEL: &str = "MACULA-PQ-REPLY-V1"; +const RELAY_ERROR_LABEL: &str = "MACULA-PQ-RELAY-ERROR-V1"; +const STREAM_LABEL: &str = "MACULA-PQ-STREAM-V1"; +const CALLER_STREAM_LABEL: &str = "MACULA-PQ-CALLER-STREAM-V1"; +const PUBLICATION_LABEL: &str = "MACULA-PQ-PUBLICATION-V1"; + +/// A protocol integer stays below 2^53; a procedure name is at most 512 +/// bytes, an error code at most 64, and an error's text at most 256. +const MAX_PROTOCOL_INT: u64 = 1 << 53; +const MAX_PROCEDURE_BYTES: usize = 512; +const MAX_ERROR_CODE_BYTES: usize = 64; +const MAX_ERROR_TEXT_BYTES: usize = 256; +const MAX_TOPIC_BYTES: usize = 512; + +/// The refusals of a frame, named as macula_frame names them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FrameError { + /// An encoding longer than [`MAX_FRAME_BYTES`], or a header claiming one. + TooLarge(usize), + /// A frame, or the signed object it carries, without exactly the shape and + /// fields of its type. + Malformed, + /// A payload the decoding rule would refuse where it arrives, and where. + Payload(String), + /// A whole frame the decoding rule would refuse, and where. + BreaksDecodingRule(String), + /// A request's delegation chain proofs outside their bound. + ProofsOutOfBound, + /// A signed object whose signer is not the key it verified with. + KeyIdMismatch, + /// A reply, relay error or stream frame naming another request. + RequestMismatch, + /// A reply, or a provider's first stream frame, from a node other than its + /// request's target. + NotTheTarget, + /// A relay error from another station than the connection's. + NotTheConnection, + /// A key that cannot sign this frame. + Unsignable, + /// A text longer than its bound, naming the field. + TextTooLong(String), + /// A text that is not valid UTF-8, naming the field. + InvalidText(String), + /// A relay error code outside its closed set. + RelayCodeOutsideItsSet, + /// A field outside its range, and which. + OutOfRange(String), + /// A stream frame its side does not send, and which. + NotAllowed(String), + /// A stream frame out of its side's order. + SeqMismatch, + /// A stream frame after its side's STREAM_END. + StreamEnded, + /// A frame given to sign that already carries a neighbour signature. + NeighbourSigned, + /// A publication published too far ahead, by how many milliseconds. + NotYetValid(i64), + /// A publication past its expiry, by how many milliseconds. + Expired(i64), + /// A signed object's signature that does not verify. + SignatureInvalid, + /// A key that could not sign. + Key(KeyError), +} + +impl fmt::Display for FrameError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + FrameError::TooLarge(n) => write!( + f, + "a frame of {n} bytes, over the {MAX_FRAME_BYTES}-byte cap" + ), + FrameError::Malformed => f.write_str("malformed frame"), + FrameError::Payload(why) | FrameError::BreaksDecodingRule(why) => f.write_str(why), + FrameError::ProofsOutOfBound => { + f.write_str("the request's proofs are outside the bound") + } + FrameError::KeyIdMismatch => { + f.write_str("the signer the frame names is not the key it verified with") + } + FrameError::RequestMismatch => f.write_str("the frame names another request"), + FrameError::NotTheTarget => f.write_str("the frame is not from the request's target"), + FrameError::NotTheConnection => { + f.write_str("the relay error is not from the connection's station") + } + FrameError::Unsignable => f.write_str("the key cannot sign this frame"), + FrameError::TextTooLong(what) => write!(f, "text longer than its bound: {what}"), + FrameError::InvalidText(what) => write!(f, "text that is not valid UTF-8: {what}"), + FrameError::RelayCodeOutsideItsSet => { + f.write_str("a relay error code outside its closed set") + } + FrameError::OutOfRange(what) => write!(f, "a field outside its range: {what}"), + FrameError::NotAllowed(what) => { + write!(f, "a stream frame its side does not send: {what}") + } + FrameError::SeqMismatch => f.write_str("a stream frame out of its side's order"), + FrameError::StreamEnded => f.write_str("a stream frame after its side's STREAM_END"), + FrameError::NeighbourSigned => { + f.write_str("the frame already carries a neighbour signature") + } + FrameError::NotYetValid(ms) => write!(f, "a publication not yet valid, by {ms} ms"), + FrameError::Expired(ms) => write!(f, "a publication past its expiry, by {ms} ms"), + FrameError::SignatureInvalid => { + f.write_str("the signed object's signature does not verify") + } + FrameError::Key(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for FrameError {} + +/// The refusal of a frame whose signed object did not verify: a signature +/// that does not verify as it is, anything else as [`FrameError::Malformed`]. +fn object_refusal(e: ObjectError) -> FrameError { + match e { + ObjectError::SignatureInvalid => FrameError::SignatureInvalid, + ObjectError::Key(k) => FrameError::Key(k), + _ => FrameError::Malformed, + } +} + +/// Wraps `frame` as ``, refusing one over +/// the frame cap. +pub fn encode(frame: &Value) -> Result, FrameError> { + let payload = cbor::encode(frame).map_err(|e| FrameError::Payload(e.to_string()))?; + if payload.len() > MAX_FRAME_BYTES { + return Err(FrameError::TooLarge(payload.len())); + } + let mut out = Vec::with_capacity(4 + payload.len()); + out.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + out.extend_from_slice(&payload); + Ok(out) +} + +/// What decoding the head of a buffer found. +#[derive(Debug, Clone, PartialEq)] +pub enum Decoded { + /// A whole frame, and how many bytes of the buffer it took. + Complete { frame: Value, consumed: usize }, + /// At least this many more bytes are needed before trying again. + NeedMore(usize), +} + +/// Decodes one length-prefixed frame from the head of `buf`, under the +/// decoding rule. +pub fn decode(buf: &[u8]) -> Result { + let Some((header, rest)) = buf.split_first_chunk::<4>() else { + return Ok(Decoded::NeedMore(4 - buf.len())); + }; + let length = u32::from_be_bytes(*header) as usize; + if length > MAX_FRAME_BYTES { + return Err(FrameError::TooLarge(length)); + } + if rest.len() < length { + return Ok(Decoded::NeedMore(length - rest.len())); + } + let frame = cbor::decode(&rest[..length]).map_err(|_| FrameError::Malformed)?; + Ok(Decoded::Complete { + frame, + consumed: 4 + length, + }) +} + +/// A field's rule, as a frame's field table names it. +#[derive(Debug, Clone, Copy)] +enum Rule { + Any, + AnyBytes, + BytesOf(usize), + TextWithin(usize), + TextIn(&'static [&'static str]), + ProtocolUint, + ProtocolVersion, + CarriedObject, + HeldObject, + StreamObject, + Proofs, +} + +impl Rule { + fn accepts(self, v: &Value) -> bool { + match self { + Rule::Any => true, + Rule::AnyBytes => matches!(v, Value::Bytes(_)), + Rule::BytesOf(n) => matches!(v, Value::Bytes(b) if b.len() == n), + Rule::TextWithin(n) => matches!(v, Value::Text(t) if t.len() <= n), + Rule::TextIn(names) => matches!(v, Value::Text(t) if names.contains(&t.as_str())), + Rule::ProtocolUint => protocol_uint(v).is_some(), + Rule::ProtocolVersion => { + matches!(v, Value::Int(n) if *n == i128::from(PROTOCOL_VERSION)) + } + Rule::CarriedObject => crate::signed_object::Object::from_value(v).is_ok(), + Rule::HeldObject => crate::signed_object::HeldObject::from_value(v).is_ok(), + Rule::StreamObject => Rule::CarriedObject.accepts(v) || Rule::HeldObject.accepts(v), + Rule::Proofs => request::proofs_within_bound(v), + } + } +} + +/// A frame's or signed object's fields by name. +type Fields = std::collections::HashMap; + +/// A map read through its table, as macula_frame's read_fields does: every +/// key text, named in the table and there once, with a value its rule +/// accepts. +fn read_fields(v: &Value, table: &[(&str, Rule)]) -> Option { + let Value::Map(pairs) = v else { + return None; + }; + let mut fields = Fields::with_capacity(pairs.len()); + for (key, value) in pairs { + let Value::Text(name) = key else { + return None; + }; + let rule = table.iter().find(|(n, _)| *n == name)?.1; + if fields.contains_key(name) || !rule.accepts(value) { + return None; + } + fields.insert(name.clone(), value.clone()); + } + Some(fields) +} + +fn has_fields(fields: &Fields, names: &[&str]) -> bool { + names.iter().all(|n| fields.contains_key(*n)) +} + +/// A received frame that carries its fields in one signed object: exactly +/// version, frame_type, the object under `object_name` and the routing fields +/// `routes` names; the protocol's version; a frame type of `types`; the object +/// in a shape `object_rule` accepts; and each routing field of its rule. +fn received_frame( + v: &Value, + object_name: &str, + object_rule: Rule, + routes: &[(&str, Rule)], + types: &'static [&'static str], +) -> Option<(String, Value)> { + let mut table = vec![ + ("version", Rule::ProtocolVersion), + ("frame_type", Rule::TextIn(types)), + (object_name, object_rule), + ]; + table.extend_from_slice(routes); + let fields = read_fields(v, &table)?; + if !has_fields(&fields, &["version", "frame_type", object_name]) { + return None; + } + Some((text_of(&fields["frame_type"]), fields[object_name].clone())) +} + +/// Refuses text longer than `max` bytes, judged first, then text that is not +/// UTF-8, naming the field. +fn bounded_text(field: &str, text: &[u8], max: usize) -> Result<(), FrameError> { + if text.len() > max { + return Err(FrameError::TextTooLong(format!( + "a {field} of {} bytes, over {max}", + text.len() + ))); + } + if std::str::from_utf8(text).is_err() { + return Err(FrameError::InvalidText(format!("the {field}"))); + } + Ok(()) +} + +/// Refuses a key that is not an identity key. +fn identity_signer(key: &NodeKey) -> Result<(), FrameError> { + if key.purpose() != Purpose::Identity { + return Err(FrameError::Unsignable); + } + Ok(()) +} + +fn protocol_uint(v: &Value) -> Option { + match v { + Value::Int(n) if *n >= 0 && *n < i128::from(MAX_PROTOCOL_INT) => Some(*n as u64), + _ => None, + } +} + +fn text_of(v: &Value) -> String { + match v { + Value::Text(t) => t.clone(), + _ => String::new(), + } +} + +fn bytes_of(v: &Value) -> Vec { + match v { + Value::Bytes(b) => b.clone(), + _ => Vec::new(), + } +} + +fn fixed(v: &Value) -> [u8; N] { + let mut out = [0u8; N]; + if let Value::Bytes(b) = v { + if b.len() == N { + out.copy_from_slice(b); + } + } + out +} + +fn entry(name: &str, value: Value) -> (Value, Value) { + (Value::text(name), value) +} + +fn uint(n: u64) -> Value { + Value::Int(i128::from(n)) +} + +/// Whether a reply's, relay error's or stream frame's request_id and +/// request_hash are `request`'s. +fn names_request(fields: &Fields, request: &VerifiedRequest) -> bool { + fields.get("request_id") == Some(&Value::Bytes(request.request_id.to_vec())) + && fields.get("request_hash") == Some(&Value::Bytes(request.request_hash.to_vec())) +} + +/// The envelope a control frame carries, as macula_frame's base/2: version, +/// frame_type, a fresh frame_id (UUID v7), sent_at_ms, capabilities, and the +/// null realm, call_id and source_route. +fn base(frame_type: &str) -> Vec<(Value, Value)> { + vec![ + entry("version", Value::Int(i128::from(PROTOCOL_VERSION))), + entry("frame_type", Value::text(frame_type)), + entry("frame_id", Value::Bytes(fresh_frame_id().to_vec())), + entry("sent_at_ms", uint(now_ms())), + entry("capabilities", uint(0)), + entry("realm", Value::Null), + entry("call_id", Value::Null), + entry("source_route", Value::Null), + ] +} + +/// Replaces `fields`' entry for `key`, or appends one: a raw push over a base +/// field would put two entries under one key. +fn with_field(mut fields: Vec<(Value, Value)>, key: &str, value: Value) -> Vec<(Value, Value)> { + match fields.iter_mut().find(|(k, _)| *k == Value::text(key)) { + Some(slot) => slot.1 = value, + None => fields.push(entry(key, value)), + } + fields +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// A UUID v7: 48 bits of Unix milliseconds, the version and variant bits, and +/// 74 random bits. +fn fresh_frame_id() -> [u8; 16] { + let mut id = [0u8; 16]; + // A frame id is an identifier, not a secret: an id without randomness is + // still unique by its time, so a failure to draw is not an error here. + let _ = aws_lc_rs::rand::fill(&mut id[6..]); + id[..6].copy_from_slice(&now_ms().to_be_bytes()[2..]); + id[6] = (id[6] & 0x0f) | 0x70; + id[8] = (id[8] & 0x3f) | 0x80; + id +} diff --git a/src/frame/check_payload.rs b/src/frame/check_payload.rs new file mode 100644 index 0000000..0e8be81 --- /dev/null +++ b/src/frame/check_payload.rs @@ -0,0 +1,146 @@ +//! The checks a sender runs so that nothing the decoding rule refuses on +//! arrival leaves this node, as macula_frame's check_payload/1 and +//! check_frame/1: a map key that is not text or an integer, two keys of one +//! map that encode alike, an integer outside -2^63 to 2^63-1, a float that is +//! NaN or infinite, too deep a nesting, and too many items. Text is valid +//! UTF-8 by construction here. + +use crate::cbor::{self, Value, MAX_ELEMENTS, MAX_NESTING_DEPTH}; + +use super::{FrameError, MAX_FRAME_BYTES}; + +/// How many lists and maps a payload may nest, the outermost counted: a +/// payload travels inside a frame's map, which takes one level. +pub const MAX_PAYLOAD_NESTING: usize = MAX_NESTING_DEPTH - 1; + +/// How many of the decoding rule's items a payload leaves for the frame +/// around it. +pub const FRAME_RESERVED_ELEMENTS: usize = 64; + +/// How many CBOR items a payload may hold, itself included. +pub const MAX_PAYLOAD_ELEMENTS: usize = MAX_ELEMENTS - FRAME_RESERVED_ELEMENTS; + +/// Whether `payload` is admissible as a frame payload. It also refuses a +/// payload whose own encoding is over the frame cap. +pub fn check_payload(payload: &Value) -> Result<(), FrameError> { + let mut check = RuleCheck { + subject: "payload", + max_items: MAX_PAYLOAD_ELEMENTS, + max_nesting: MAX_PAYLOAD_NESTING, + items: 0, + }; + check + .value(payload, &mut Vec::new()) + .map_err(FrameError::Payload)?; + let encoded = cbor::encode(payload).map_err(|e| FrameError::Payload(e.to_string()))?; + if encoded.len() > MAX_FRAME_BYTES { + return Err(FrameError::Payload(format!( + "the payload encodes to {} bytes, over the {MAX_FRAME_BYTES}-byte frame cap", + encoded.len() + ))); + } + Ok(()) +} + +/// Whether the whole `frame` is one the decoding rule accepts where it +/// arrives, the check macula runs on every frame before it is sent. +pub fn check_frame(frame: &Value) -> Result<(), FrameError> { + let mut check = RuleCheck { + subject: "frame", + max_items: MAX_ELEMENTS, + max_nesting: MAX_NESTING_DEPTH, + items: 0, + }; + check + .value(frame, &mut Vec::new()) + .map_err(FrameError::BreaksDecodingRule) +} + +/// A walk of a payload or a whole frame, its subject, under the decoding +/// rule's limits for it, counting its items. +struct RuleCheck { + subject: &'static str, + max_items: usize, + max_nesting: usize, + items: usize, +} + +impl RuleCheck { + fn value(&mut self, v: &Value, path: &mut Vec) -> Result<(), String> { + self.items += 1; + if self.items > self.max_items { + return Err(format!( + "the {} holds more than {} items, at {}", + self.subject, + self.max_items, + self.at(path) + )); + } + match v { + Value::Float(f) if !f.is_finite() => { + Err(format!("a float that is not finite at {}", self.at(path))) + } + Value::Int(n) if i64::try_from(*n).is_err() => Err(format!( + "an integer outside -2^63 to 2^63-1 at {}", + self.at(path) + )), + Value::List(items) => { + self.nesting(path)?; + for (i, item) in items.iter().enumerate() { + path.push(i.to_string()); + self.value(item, path)?; + path.pop(); + } + Ok(()) + } + Value::Map(pairs) => { + self.nesting(path)?; + let mut seen = std::collections::HashSet::with_capacity(pairs.len()); + for (key, value) in pairs { + if !matches!(key, Value::Text(_) | Value::Int(_)) { + return Err(format!( + "a map key that is not text or an integer at {}", + self.at(path) + )); + } + self.value(key, path)?; + let encoded = cbor::encode(key).map_err(|e| e.to_string())?; + if !seen.insert(encoded) { + return Err(format!( + "two keys of the map at {} encode alike", + self.at(path) + )); + } + path.push(match key { + Value::Text(t) => t.clone(), + other => format!("{other:?}"), + }); + self.value(value, path)?; + path.pop(); + } + Ok(()) + } + _ => Ok(()), + } + } + + /// Refuses a list or map at `path` that would nest more than the limit. + fn nesting(&self, path: &[String]) -> Result<(), String> { + if path.len() >= self.max_nesting { + return Err(format!( + "lists and maps at {} nest more than {} levels", + self.at(path), + self.max_nesting + )); + } + Ok(()) + } + + fn at(&self, path: &[String]) -> String { + if path.is_empty() { + format!("the {} root", self.subject) + } else { + path.join(".") + } + } +} diff --git a/src/frame/neighbour.rs b/src/frame/neighbour.rs new file mode 100644 index 0000000..b7f5a70 --- /dev/null +++ b/src/frame/neighbour.rs @@ -0,0 +1,298 @@ +//! Neighbour signatures (D17). In pq_hybrid a control frame travels as +//! `{version, frame_type, neighbour}`: `neighbour` is a held signed object +//! under MACULA-PQ-NEIGHBOUR-V1 by the sender's identity key, which the +//! receiver holds from the handshake. Its tbs holds the frame's fields +//! (without version), alg, the connection hash (the SHA-384 of the CHALLENGE +//! frame's bytes) and seq: 0 on the first neighbour-signed frame in each +//! direction, one more on each after. In pq_pure no frame carries one. +//! +//! The builders here are the control frames a client link sends: ADVERTISE +//! and UNADVERTISE carry a signed record, SUBSCRIBE and UNSUBSCRIBE a topic, +//! and GOODBYE a reason. + +use crate::cbor::Value; +use crate::node_key::NodeKey; +use crate::profile::Profile; +use crate::signed_object::{sign_held_object, verify_held_object}; + +use super::{ + base, bounded_text, entry, has_fields, object_refusal, protocol_uint, read_fields, uint, + with_field, FrameError, Rule, MAX_TOPIC_BYTES, PROTOCOL_VERSION, +}; + +const NEIGHBOUR_LABEL: &str = "MACULA-PQ-NEIGHBOUR-V1"; +const MAX_GOODBYE_REASON_BYTES: usize = 256; +const MAX_GOODBYE_DETAIL_BYTES: usize = 256; + +/// The control frames pq_hybrid neighbour-signs, as macula_frame lists them. +/// Data frames carry their own end-to-end signatures. +const NEIGHBOUR_SIGNED_TYPES: &[&str] = &[ + "swim_ping", + "swim_ack", + "swim_suspect", + "swim_confirm", + "ping", + "pong", + "find_node", + "nodes", + "find_value", + "value", + "store", + "store_ack", + "advertise", + "unadvertise", + "subscribe", + "unsubscribe", + "overlay_relay", + "hyparview_join", + "hyparview_forward_join", + "hyparview_neighbor", + "hyparview_disconnect", + "hyparview_shuffle", + "hyparview_shuffle_reply", + "plumtree_ihave", + "plumtree_graft", + "plumtree_prune", + "goodbye", +]; + +/// Whether `profile` neighbour-signs frames of `frame_type`: every control +/// frame in pq_hybrid, none in pq_pure. +pub fn neighbour_signed(profile: Profile, frame_type: &str) -> bool { + profile == Profile::PqHybrid && NEIGHBOUR_SIGNED_TYPES.contains(&frame_type) +} + +/// Where a sender neighbour-signs a frame: the connection hash and the seq of +/// this frame in the sender's direction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NeighbourLink { + pub connection: [u8; 48], + pub seq: u64, +} + +/// What a receiver checks a frame against: the connection's profile, the +/// peer's identity key as carried, the connection hash, and the seq it +/// expects next from that peer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NeighbourPeer { + pub profile: Profile, + pub peer_key: Vec, + pub connection: [u8; 48], + pub seq: u64, +} + +/// Neighbour-signs `frame` with the sender's identity key, for one connection +/// and one seq, when the key's profile signs its type; otherwise `frame` goes +/// as it is. A frame that already carries a neighbour signature is refused. +pub fn sign_neighbour( + frame: &Value, + key: &NodeKey, + link: &NeighbourLink, +) -> Result { + let Value::Map(pairs) = frame else { + return Err(FrameError::Malformed); + }; + let (frame_type, version, has_neighbour) = control_header(pairs); + if has_neighbour { + return Err(FrameError::NeighbourSigned); + } + if !neighbour_signed(key.profile(), &frame_type) { + return Ok(frame.clone()); + } + let mut fields: Vec<(Value, Value)> = pairs + .iter() + .filter(|(k, _)| *k != Value::text("version")) + .cloned() + .collect(); + fields.push(entry("connection", Value::Bytes(link.connection.to_vec()))); + fields.push(entry("seq", uint(link.seq))); + let held = sign_held_object(NEIGHBOUR_LABEL, &fields, key).map_err(object_refusal)?; + Ok(Value::Map(vec![ + entry("version", version), + entry("frame_type", Value::text(frame_type)), + entry("neighbour", held.to_value()), + ])) +} + +/// Reads a received frame under the connection's profile. A frame type the +/// profile signs must be exactly `{version, frame_type, neighbour}`, signed by +/// the peer's identity key for this connection and this seq, and comes back as +/// the frame its tbs holds. Any other frame must not carry `neighbour` and +/// comes back as it is. +pub fn verify_neighbour(frame: &Value, peer: &NeighbourPeer) -> Result { + let Value::Map(pairs) = frame else { + return Err(FrameError::Malformed); + }; + let (frame_type, version, has_neighbour) = control_header(pairs); + if !neighbour_signed(peer.profile, &frame_type) { + return if has_neighbour { + Err(FrameError::Malformed) + } else { + Ok(frame.clone()) + }; + } + if pairs.len() != 3 || !has_neighbour { + return Err(FrameError::Malformed); + } + let neighbour = frame.get("neighbour").ok_or(FrameError::Malformed)?; + let verified = verify_held_object(NEIGHBOUR_LABEL, neighbour, &peer.peer_key, peer.profile) + .map_err(object_refusal)?; + opened_control_frame(&verified.fields, &frame_type, version, peer) +} + +/// The frame a neighbour tbs holds: read under its type's table with alg, +/// connection and seq, which must name this connection and this seq, and +/// returned without them and with version back. +fn opened_control_frame( + tbs: &Value, + frame_type: &str, + version: Value, + peer: &NeighbourPeer, +) -> Result { + let mut table = control_table(frame_type).ok_or(FrameError::Malformed)?; + table.extend([ + ("alg", Rule::Any), + ("connection", Rule::BytesOf(48)), + ("seq", Rule::ProtocolUint), + ]); + let fields = read_fields(tbs, &table).ok_or(FrameError::Malformed)?; + if !has_fields(&fields, &["frame_type", "alg", "connection", "seq"]) + || fields["frame_type"] != Value::text(frame_type) + || fields["connection"] != Value::Bytes(peer.connection.to_vec()) + || protocol_uint(&fields["seq"]) != Some(peer.seq) + { + return Err(FrameError::Malformed); + } + let Value::Map(pairs) = tbs else { + return Err(FrameError::Malformed); + }; + let mut opened = vec![entry("version", version)]; + opened.extend( + pairs + .iter() + .filter(|(k, _)| !matches!(k, Value::Text(t) if t == "alg" || t == "connection" || t == "seq")) + .cloned(), + ); + Ok(Value::Map(opened)) +} + +/// The field table of a control frame a client link exchanges, without +/// version and neighbour: the base every frame carries and the type's own. +fn control_table(frame_type: &str) -> Option> { + let own: &[(&'static str, Rule)] = match frame_type { + "advertise" => &[("advertisement", Rule::AnyBytes)], + "unadvertise" => &[("withdrawal", Rule::AnyBytes)], + "subscribe" => &[ + ("topic", Rule::Any), + ("subscriber", Rule::BytesOf(32)), + ("options", Rule::Any), + ], + "unsubscribe" => &[("topic", Rule::Any), ("subscriber", Rule::BytesOf(32))], + "goodbye" => &[ + ("reason", Rule::TextWithin(MAX_GOODBYE_REASON_BYTES)), + ("detail", Rule::Any), + ], + _ => return None, + }; + let types: &'static [&'static str] = match frame_type { + "advertise" => &["advertise"], + "unadvertise" => &["unadvertise"], + "subscribe" => &["subscribe"], + "unsubscribe" => &["unsubscribe"], + _ => &["goodbye"], + }; + let mut table = vec![ + ("frame_type", Rule::TextIn(types)), + ("frame_id", Rule::Any), + ("sent_at_ms", Rule::ProtocolUint), + ("capabilities", Rule::ProtocolUint), + ("realm", Rule::Any), + ("call_id", Rule::Any), + ("source_route", Rule::Any), + ]; + table.extend_from_slice(own); + Some(table) +} + +/// A frame map's frame_type, version, and whether it carries neighbour. +fn control_header(pairs: &[(Value, Value)]) -> (String, Value, bool) { + let mut frame_type = String::new(); + let mut version = Value::Int(i128::from(PROTOCOL_VERSION)); + let mut has_neighbour = false; + for (k, v) in pairs { + match (k, v) { + (Value::Text(n), Value::Text(t)) if n == "frame_type" => frame_type = t.clone(), + (Value::Text(n), _) if n == "version" => version = v.clone(), + (Value::Text(n), _) if n == "neighbour" => has_neighbour = true, + _ => {} + } + } + (frame_type, version, has_neighbour) +} + +/// macula 12's ADVERTISE: the signed procedure_advertisement record, as +/// encoded bytes. +pub fn advertise_frame(advertisement: &[u8]) -> Value { + let mut fields = base("advertise"); + fields.push(entry("advertisement", Value::Bytes(advertisement.to_vec()))); + Value::Map(fields) +} + +/// macula 12's UNADVERTISE: the signed withdrawal record, as encoded bytes. +pub fn unadvertise_frame(withdrawal: &[u8]) -> Value { + let mut fields = base("unadvertise"); + fields.push(entry("withdrawal", Value::Bytes(withdrawal.to_vec()))); + Value::Map(fields) +} + +/// macula 12's SUBSCRIBE of `subscriber` to `topic` in `realm`, with no +/// options. A topic over 512 bytes or not UTF-8 is refused. +pub fn subscribe_frame( + topic: &[u8], + realm: &[u8; 32], + subscriber: &[u8; 32], +) -> Result { + let mut fields = topic_frame("subscribe", topic, realm, subscriber)?; + fields.push(entry("options", Value::Map(Vec::new()))); + Ok(Value::Map(fields)) +} + +/// macula 12's UNSUBSCRIBE of `subscriber` from `topic` in `realm`, with +/// [`subscribe_frame`]'s bound on the topic. +pub fn unsubscribe_frame( + topic: &[u8], + realm: &[u8; 32], + subscriber: &[u8; 32], +) -> Result { + topic_frame("unsubscribe", topic, realm, subscriber).map(Value::Map) +} + +fn topic_frame( + frame_type: &str, + topic: &[u8], + realm: &[u8; 32], + subscriber: &[u8; 32], +) -> Result, FrameError> { + bounded_text("topic", topic, MAX_TOPIC_BYTES)?; + let mut fields = with_field(base(frame_type), "realm", Value::Bytes(realm.to_vec())); + fields.push(entry("topic", Value::Bytes(topic.to_vec()))); + fields.push(entry("subscriber", Value::Bytes(subscriber.to_vec()))); + Ok(fields) +} + +/// macula 12's GOODBYE: a reason of at most 256 bytes, and a detail of at +/// most 256 bytes of UTF-8, or none. +pub fn goodbye_frame(reason: &str, detail: Option<&[u8]>) -> Result { + bounded_text("reason", reason.as_bytes(), MAX_GOODBYE_REASON_BYTES)?; + let detail = match detail { + Some(d) => { + bounded_text("detail", d, MAX_GOODBYE_DETAIL_BYTES)?; + Value::Bytes(d.to_vec()) + } + None => Value::Null, + }; + let mut fields = base("goodbye"); + fields.push(entry("reason", Value::text(reason))); + fields.push(entry("detail", detail)); + Ok(Value::Map(fields)) +} diff --git a/src/frame/publication.rs b/src/frame/publication.rs new file mode 100644 index 0000000..839eb42 --- /dev/null +++ b/src/frame/publication.rs @@ -0,0 +1,173 @@ +//! Publications (D17): signed under MACULA-PQ-PUBLICATION-V1 by the +//! publisher's identity key, with no frame_type in the tbs, since the same +//! bytes ride in every EVENT and GOSSIP made from a PUBLISH. A verifier +//! accepts one published up to 5 minutes ahead of its clock, until its +//! ttl_ms, or 10 minutes without one, and 5 minutes more; a ttl_ms is at most +//! one hour. + +use sha2::{Digest, Sha384}; + +use crate::cbor::Value; +use crate::node_key::{node_id_of, NodeKey}; +use crate::profile::Profile; +use crate::signed_object::{sign_object, verify_object}; + +use super::{ + bounded_text, check_payload, entry, fixed, has_fields, identity_signer, object_refusal, + protocol_uint, read_fields, received_frame, text_of, uint, FrameError, Rule, MAX_PROTOCOL_INT, + MAX_TOPIC_BYTES, PROTOCOL_VERSION, PUBLICATION_LABEL, +}; + +const PUBLISH: &str = "publish"; +const EVENT: &str = "event"; +const PLUMTREE_GOSSIP: &str = "plumtree_gossip"; +const TOLERANCE_MS: u64 = 5 * 60_000; +const DEFAULT_TTL_MS: u64 = 10 * 60_000; +const MAX_TTL_MS: u64 = 60 * 60_000; + +/// A publication as its publisher gives it: a realm, a topic, the publisher's +/// own seq, when it was published in Unix milliseconds, a payload, and a +/// ttl_ms, `None` for the 10 minutes a publication lives without one. +#[derive(Debug, Clone, PartialEq)] +pub struct PublicationSpec { + pub realm: [u8; 32], + pub topic: String, + pub seq: u64, + pub published_at: u64, + pub payload: Value, + pub ttl_ms: Option, +} + +/// A publication that verified: its fields, the publisher's key as carried, +/// `publication_hash`, the SHA-384 of its tbs, which deduplication keys on, +/// and `expires_at`, the last moment a verifier accepts it. +#[derive(Debug, Clone, PartialEq)] +pub struct VerifiedPublication { + pub publisher: [u8; 32], + pub realm: [u8; 32], + pub topic: String, + pub seq: u64, + pub published_at: u64, + pub ttl_ms: Option, + pub payload: Value, + pub key: Vec, + pub publication_hash: [u8; 48], + pub expires_at: u64, +} + +/// Signs a publication as a PUBLISH with the publisher's identity key. +/// Refused, in macula's order: a key that is not an identity key; a seq or +/// published_at of 2^53 or more; a topic over 512 bytes; a payload the wire +/// cannot carry; a ttl_ms over one hour. +pub fn sign_publish(spec: &PublicationSpec, key: &NodeKey) -> Result { + identity_signer(key)?; + if spec.seq >= MAX_PROTOCOL_INT || spec.published_at >= MAX_PROTOCOL_INT { + return Err(FrameError::OutOfRange( + "a seq or published_at of 2^53 or more".into(), + )); + } + bounded_text("topic", spec.topic.as_bytes(), MAX_TOPIC_BYTES)?; + check_payload(&spec.payload)?; + if spec.ttl_ms.is_some_and(|t| t > MAX_TTL_MS) { + return Err(FrameError::OutOfRange("a ttl_ms over one hour".into())); + } + let mut fields = vec![ + entry("publisher", Value::Bytes(key.key_id().to_vec())), + entry("realm", Value::Bytes(spec.realm.to_vec())), + entry("topic", Value::text(spec.topic.clone())), + entry("seq", uint(spec.seq)), + entry("published_at", uint(spec.published_at)), + entry("payload", spec.payload.clone()), + ]; + if let Some(ttl) = spec.ttl_ms { + fields.push(entry("ttl_ms", uint(ttl))); + } + let publication = sign_object(PUBLICATION_LABEL, &fields, key).map_err(object_refusal)?; + Ok(Value::Map(vec![ + entry("version", Value::Int(i128::from(PROTOCOL_VERSION))), + entry("frame_type", Value::text(PUBLISH)), + entry("publication", publication.to_value()), + ])) +} + +const PUBLICATION_TABLE: &[(&str, Rule)] = &[ + ("alg", Rule::Any), + ("publisher", Rule::BytesOf(32)), + ("realm", Rule::BytesOf(32)), + ("topic", Rule::TextWithin(MAX_TOPIC_BYTES)), + ("seq", Rule::ProtocolUint), + ("published_at", Rule::ProtocolUint), + ("ttl_ms", Rule::ProtocolUint), + ("payload", Rule::Any), +]; + +/// Verifies the publication a received PUBLISH, EVENT or GOSSIP carries, +/// under the connection's `profile` and the verifier's clock `now_ms`: the +/// frame is exactly version, frame_type and publication, with an EVENT's +/// delivered_via or a GOSSIP's round; then the publication's signature and +/// fields, a ttl_ms of at most an hour, publisher as the key id of its key, +/// and its time. +pub fn verify_publication( + frame: &Value, + profile: Profile, + now_ms: i64, +) -> Result { + let frame_type = frame.get("frame_type").map(text_of).unwrap_or_default(); + let (extra, types): (&[(&str, Rule)], &'static [&'static str]) = match frame_type.as_str() { + PUBLISH => (&[], &[PUBLISH]), + EVENT => ( + &[("delivered_via", Rule::TextIn(&["plumtree", "direct"]))], + &[EVENT], + ), + PLUMTREE_GOSSIP => (&[("round", Rule::ProtocolUint)], &[PLUMTREE_GOSSIP]), + _ => return Err(FrameError::Malformed), + }; + let (_, object) = received_frame(frame, "publication", Rule::CarriedObject, extra, types) + .ok_or(FrameError::Malformed)?; + if extra.iter().any(|(name, _)| frame.get(name).is_none()) { + return Err(FrameError::Malformed); + } + let verified = verify_object(PUBLICATION_LABEL, &object, profile).map_err(object_refusal)?; + let fields = read_fields(&verified.fields, PUBLICATION_TABLE).ok_or(FrameError::Malformed)?; + if !has_fields( + &fields, + &[ + "publisher", + "realm", + "topic", + "seq", + "published_at", + "payload", + ], + ) { + return Err(FrameError::Malformed); + } + let ttl_ms = fields.get("ttl_ms").and_then(protocol_uint); + let published_at = protocol_uint(&fields["published_at"]).unwrap_or(0); + let publication = VerifiedPublication { + publisher: fixed(&fields["publisher"]), + realm: fixed(&fields["realm"]), + topic: text_of(&fields["topic"]), + seq: protocol_uint(&fields["seq"]).unwrap_or(0), + published_at, + ttl_ms, + payload: fields["payload"].clone(), + publication_hash: Sha384::digest(&verified.tbs).into(), + expires_at: published_at + ttl_ms.unwrap_or(DEFAULT_TTL_MS) + TOLERANCE_MS, + key: verified.key, + }; + let valid_from = published_at as i64 - TOLERANCE_MS as i64; + if ttl_ms.is_some_and(|t| t > MAX_TTL_MS) { + return Err(FrameError::Malformed); + } + if publication.publisher != node_id_of(&publication.key, profile) { + return Err(FrameError::KeyIdMismatch); + } + if valid_from > now_ms { + return Err(FrameError::NotYetValid(valid_from - now_ms)); + } + if now_ms > publication.expires_at as i64 { + return Err(FrameError::Expired(now_ms - publication.expires_at as i64)); + } + Ok(publication) +} diff --git a/src/frame/reply.rs b/src/frame/reply.rs new file mode 100644 index 0000000..1b7584c --- /dev/null +++ b/src/frame/reply.rs @@ -0,0 +1,393 @@ +//! Replies and relay errors (D25): a provider's RESULT or ERROR, signed under +//! MACULA-PQ-REPLY-V1 by the request's target, and a station's relay ERROR or +//! STREAM_ERROR, signed under MACULA-PQ-RELAY-ERROR-V1 with a code from a +//! closed set and no free text. + +use crate::cbor::{self, Value}; +use crate::node_key::{node_id_of, NodeKey}; +use crate::profile::Profile; +use crate::signed_object::{sign_object, verify_object, Object}; + +use super::{ + bounded_text, check_payload, entry, fixed, has_fields, identity_signer, names_request, + object_refusal, read_fields, received_frame, text_of, FrameError, Rule, VerifiedRequest, + MAX_ERROR_CODE_BYTES, MAX_ERROR_TEXT_BYTES, PROTOCOL_VERSION, RELAY_ERROR_LABEL, REPLY_LABEL, +}; + +const RESULT: &str = "result"; +const ERROR: &str = "error"; +const STREAM_ERROR: &str = "stream_error"; + +/// The closed set of relay error codes, disjoint from every provider code. +const RELAY_CODES: &[&str] = &["unknown_next_peer"]; + +/// A reply's frame type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplyType { + Result, + Error, +} + +/// A relay error's frame type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RelayErrorType { + Error, + StreamError, +} + +impl RelayErrorType { + fn name(self) -> &'static str { + match self { + RelayErrorType::Error => ERROR, + RelayErrorType::StreamError => STREAM_ERROR, + } + } +} + +/// A provider's RESULT or ERROR that verified for its request: the node that +/// responded, a RESULT's payload, and an ERROR's code and detail. +#[derive(Debug, Clone, PartialEq)] +pub struct VerifiedReply { + pub frame_type: ReplyType, + pub responded_by: [u8; 32], + pub payload: Option, + pub code: Option, + pub detail: Option, +} + +/// A station's relay error as it gives it: for a pending verified request, a +/// code from the closed set, the hop that failed, and a routing field outside +/// the signature. +#[derive(Debug, Clone, PartialEq)] +pub struct RelayErrorSpec { + pub frame_type: RelayErrorType, + pub request: VerifiedRequest, + pub code: String, + pub offending_hop: Option<[u8; 32]>, + pub source_route_partial: Option>, +} + +/// A relay error that verified for its request: the station that reported +/// it, its code, and the hop that failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedRelayError { + pub frame_type: RelayErrorType, + pub reported_by: [u8; 32], + pub code: String, + pub offending_hop: Option<[u8; 32]>, +} + +/// Signs a provider's RESULT for a verified request: responded_by is the +/// key's key id, which must be the request's target, and the payload one the +/// wire carries. `source_route_reverse` rides outside the signature. +pub fn sign_result( + request: &VerifiedRequest, + payload: &Value, + source_route_reverse: Option>, + key: &NodeKey, +) -> Result { + reply_signer(request, key)?; + check_payload(payload)?; + sign_reply( + ReplyType::Result, + request, + vec![entry("payload", payload.clone())], + source_route_reverse, + key, + ) +} + +/// Signs a provider's ERROR for a verified request, with [`sign_result`]'s +/// key check: a code of at most 64 bytes and a detail of at most 256. +pub fn sign_provider_error( + request: &VerifiedRequest, + code: &str, + detail: Option<&str>, + source_route_reverse: Option>, + key: &NodeKey, +) -> Result { + reply_signer(request, key)?; + bounded_text("code", code.as_bytes(), MAX_ERROR_CODE_BYTES)?; + let mut fields = vec![entry("code", Value::text(code))]; + if let Some(detail) = detail { + bounded_text("detail", detail.as_bytes(), MAX_ERROR_TEXT_BYTES)?; + fields.push(entry("detail", Value::text(detail))); + } + sign_reply(ReplyType::Error, request, fields, source_route_reverse, key) +} + +fn reply_signer(request: &VerifiedRequest, key: &NodeKey) -> Result<(), FrameError> { + identity_signer(key)?; + if key.key_id() != request.target { + return Err(FrameError::Unsignable); + } + Ok(()) +} + +fn sign_reply( + frame_type: ReplyType, + request: &VerifiedRequest, + mut fields: Vec<(Value, Value)>, + source_route_reverse: Option>, + key: &NodeKey, +) -> Result { + let name = match frame_type { + ReplyType::Result => RESULT, + ReplyType::Error => ERROR, + }; + fields.extend([ + entry("frame_type", Value::text(name)), + entry("request_id", Value::Bytes(request.request_id.to_vec())), + entry("request_hash", Value::Bytes(request.request_hash.to_vec())), + entry("responded_by", Value::Bytes(key.key_id().to_vec())), + ]); + let reply = sign_object(REPLY_LABEL, &fields, key).map_err(object_refusal)?; + Ok(routed_frame( + name, + "reply", + &reply, + "source_route_reverse", + source_route_reverse, + )) +} + +const REPLY_ROUTES: &[(&str, Rule)] = &[("source_route_reverse", Rule::AnyBytes)]; +const RELAY_ERROR_ROUTES: &[(&str, Rule)] = &[("source_route_partial", Rule::AnyBytes)]; + +/// Verifies a received RESULT or provider ERROR for the request it answers: +/// the frame's shape, the reply's signature and fields, responded_by as the +/// key id of its key, the request's request_id and request_hash, and +/// responded_by as the request's target. +pub fn verify_reply( + frame: &Value, + request: &VerifiedRequest, + profile: Profile, +) -> Result { + let (frame_type, object) = received_frame( + frame, + "reply", + Rule::CarriedObject, + REPLY_ROUTES, + &[RESULT, ERROR], + ) + .ok_or(FrameError::Malformed)?; + let verified = verify_object(REPLY_LABEL, &object, profile).map_err(object_refusal)?; + let fields = + read_fields(&verified.fields, &reply_table(&frame_type)).ok_or(FrameError::Malformed)?; + let (has_payload, has_code, has_detail) = ( + fields.contains_key("payload"), + fields.contains_key("code"), + fields.contains_key("detail"), + ); + let shaped = if frame_type == RESULT { + has_payload && !has_code && !has_detail + } else { + has_code && !has_payload + }; + if !has_fields( + &fields, + &["frame_type", "request_id", "request_hash", "responded_by"], + ) || !shaped + { + return Err(FrameError::Malformed); + } + let reply = VerifiedReply { + frame_type: if frame_type == RESULT { + ReplyType::Result + } else { + ReplyType::Error + }, + responded_by: fixed(&fields["responded_by"]), + payload: fields.get("payload").cloned(), + code: fields.get("code").map(text_of), + detail: fields.get("detail").map(text_of), + }; + if reply.responded_by != node_id_of(&verified.key, profile) { + return Err(FrameError::KeyIdMismatch); + } + if !names_request(&fields, request) { + return Err(FrameError::RequestMismatch); + } + if reply.responded_by != request.target { + return Err(FrameError::NotTheTarget); + } + Ok(reply) +} + +fn reply_table(frame_type: &str) -> Vec<(&'static str, Rule)> { + vec![ + ( + "frame_type", + Rule::TextIn(if frame_type == RESULT { + &[RESULT] + } else { + &[ERROR] + }), + ), + ("alg", Rule::Any), + ("request_id", Rule::BytesOf(16)), + ("request_hash", Rule::BytesOf(48)), + ("responded_by", Rule::BytesOf(32)), + ("payload", Rule::Any), + ("code", Rule::TextWithin(MAX_ERROR_CODE_BYTES)), + ("detail", Rule::TextWithin(MAX_ERROR_TEXT_BYTES)), + ] +} + +/// Signs a station's relay error with its identity key: reported_by is the +/// key's key id. Refused, in this order: a key that is not an identity key, a +/// code outside the closed set. +pub fn sign_relay_error(spec: &RelayErrorSpec, key: &NodeKey) -> Result { + identity_signer(key)?; + if !RELAY_CODES.contains(&spec.code.as_str()) { + return Err(FrameError::RelayCodeOutsideItsSet); + } + let name = spec.frame_type.name(); + let mut fields = vec![ + entry("frame_type", Value::text(name)), + entry("request_id", Value::Bytes(spec.request.request_id.to_vec())), + entry( + "request_hash", + Value::Bytes(spec.request.request_hash.to_vec()), + ), + entry("reported_by", Value::Bytes(key.key_id().to_vec())), + entry("code", Value::text(spec.code.clone())), + ]; + if let Some(hop) = spec.offending_hop { + fields.push(entry("offending_hop", Value::Bytes(hop.to_vec()))); + } + let relay_error = sign_object(RELAY_ERROR_LABEL, &fields, key).map_err(object_refusal)?; + Ok(routed_frame( + name, + "relay_error", + &relay_error, + "source_route_partial", + spec.source_route_partial.clone(), + )) +} + +/// Verifies a received relay error for the pending request it names, from +/// the station the connection authenticated, `expected_reporter`. +pub fn verify_relay_error( + frame: &Value, + request: &VerifiedRequest, + profile: Profile, + expected_reporter: &[u8; 32], +) -> Result { + let (frame_type, object) = received_frame( + frame, + "relay_error", + Rule::CarriedObject, + RELAY_ERROR_ROUTES, + &[ERROR, STREAM_ERROR], + ) + .ok_or(FrameError::Malformed)?; + let verified = verify_object(RELAY_ERROR_LABEL, &object, profile).map_err(object_refusal)?; + let fields = read_fields(&verified.fields, &relay_error_table(&frame_type)) + .ok_or(FrameError::Malformed)?; + if !has_fields( + &fields, + &[ + "frame_type", + "request_id", + "request_hash", + "reported_by", + "code", + ], + ) { + return Err(FrameError::Malformed); + } + let relay_error = VerifiedRelayError { + frame_type: if frame_type == ERROR { + RelayErrorType::Error + } else { + RelayErrorType::StreamError + }, + reported_by: fixed(&fields["reported_by"]), + code: text_of(&fields["code"]), + offending_hop: fields.get("offending_hop").map(fixed), + }; + if relay_error.reported_by != node_id_of(&verified.key, profile) { + return Err(FrameError::KeyIdMismatch); + } + if !names_request(&fields, request) { + return Err(FrameError::RequestMismatch); + } + if &relay_error.reported_by != expected_reporter { + return Err(FrameError::NotTheConnection); + } + Ok(relay_error) +} + +fn relay_error_table(frame_type: &str) -> Vec<(&'static str, Rule)> { + vec![ + ( + "frame_type", + Rule::TextIn(if frame_type == ERROR { + &[ERROR] + } else { + &[STREAM_ERROR] + }), + ), + ("alg", Rule::Any), + ("request_id", Rule::BytesOf(16)), + ("request_hash", Rule::BytesOf(48)), + ("reported_by", Rule::BytesOf(32)), + ("code", Rule::TextIn(RELAY_CODES)), + ("offending_hop", Rule::BytesOf(32)), + ] +} + +/// The request_id and request_hash a received reply or relay error names, +/// read without verifying it: a key for finding the pending request and +/// nothing more. The frame's fields and the signed object's shape are checked +/// as the verifiers check them, so ids of another length or shape never come +/// back. +pub fn claimed_reply_ids(frame: &Value) -> Result<([u8; 16], [u8; 48]), FrameError> { + let frame_type = frame.get("frame_type").map(text_of).unwrap_or_default(); + let (object_name, routes, table) = match (frame.get("reply"), frame.get("relay_error")) { + (Some(_), _) if frame_type == RESULT || frame_type == ERROR => { + ("reply", REPLY_ROUTES, reply_table(&frame_type)) + } + (_, Some(_)) if frame_type == ERROR || frame_type == STREAM_ERROR => ( + "relay_error", + RELAY_ERROR_ROUTES, + relay_error_table(&frame_type), + ), + _ => return Err(FrameError::Malformed), + }; + let types: &'static [&'static str] = match frame_type.as_str() { + RESULT => &[RESULT], + ERROR => &[ERROR], + _ => &[STREAM_ERROR], + }; + let (_, object) = received_frame(frame, object_name, Rule::CarriedObject, routes, types) + .ok_or(FrameError::Malformed)?; + let parsed = Object::from_value(&object).map_err(|_| FrameError::Malformed)?; + let tbs = cbor::decode(&parsed.tbs).map_err(|_| FrameError::Malformed)?; + let fields = read_fields(&tbs, &table).ok_or(FrameError::Malformed)?; + if !has_fields(&fields, &["frame_type", "request_id", "request_hash"]) { + return Err(FrameError::Malformed); + } + Ok((fixed(&fields["request_id"]), fixed(&fields["request_hash"]))) +} + +/// A frame of `frame_type` carrying `object` under `object_name`, with the +/// routing field `route_name` when there is one. +fn routed_frame( + frame_type: &str, + object_name: &str, + object: &Object, + route_name: &str, + route: Option>, +) -> Value { + let mut entries = vec![ + entry("version", Value::Int(i128::from(PROTOCOL_VERSION))), + entry("frame_type", Value::text(frame_type)), + entry(object_name, object.to_value()), + ]; + if let Some(route) = route { + entries.push(entry(route_name, Value::Bytes(route))); + } + Value::Map(entries) +} diff --git a/src/frame/request.rs b/src/frame/request.rs new file mode 100644 index 0000000..8a77475 --- /dev/null +++ b/src/frame/request.rs @@ -0,0 +1,290 @@ +//! Requests (D25): a CALL or STREAM_OPEN, a signed object under +//! MACULA-PQ-REQUEST-V1 by the caller's identity key, with routing fields +//! outside the signature. + +use sha2::{Digest, Sha384}; + +use crate::cbor::Value; +use crate::node_key::{node_id_of, NodeKey}; +use crate::profile::Profile; +use crate::signed_object::{sign_object, verify_object}; + +use super::{ + bounded_text, check_payload, entry, fixed, has_fields, identity_signer, object_refusal, + protocol_uint, read_fields, received_frame, text_of, uint, FrameError, Rule, StreamMode, + MAX_PROCEDURE_BYTES, MAX_PROTOCOL_INT, PROTOCOL_VERSION, REQUEST_LABEL, +}; + +/// The bound on a request's proofs (D7, chain transport): eight tokens, 256 +/// KiB in all, none repeated. +pub const MAX_PROOFS: usize = 8; +pub const MAX_PROOFS_BYTES: usize = 256 * 1024; + +const CALL: &str = "call"; +const STREAM_OPEN: &str = "stream_open"; + +/// A request's frame type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequestType { + Call, + StreamOpen, +} + +impl RequestType { + fn name(self) -> &'static str { + match self { + RequestType::Call => CALL, + RequestType::StreamOpen => STREAM_OPEN, + } + } +} + +/// A request as its caller gives it: `mode` is a STREAM_OPEN's and `None` for +/// a CALL; `token` is `None` when the request carries none; `proofs` are the +/// tokens of the delegation chain the token rests on, empty for none; +/// `source_route` and `retry_budget` are routing fields outside the +/// signature. +#[derive(Debug, Clone, PartialEq)] +pub struct RequestSpec { + pub request_id: [u8; 16], + pub realm: [u8; 32], + pub procedure: String, + pub target: [u8; 32], + pub deadline: u64, + pub payload: Value, + pub mode: Option, + pub token: Option>, + pub proofs: Vec>, + pub source_route: Option>, + pub retry_budget: Option, +} + +/// A CALL or STREAM_OPEN whose request verified: its fields, the caller's key +/// as carried, and `request_hash`, the SHA-384 of its tbs, which replies and +/// stream frames name. +#[derive(Debug, Clone, PartialEq)] +pub struct VerifiedRequest { + pub frame_type: RequestType, + pub key: Vec, + pub request_hash: [u8; 48], + pub caller: [u8; 32], + pub request_id: [u8; 16], + pub realm: [u8; 32], + pub procedure: String, + pub target: [u8; 32], + pub deadline: u64, + pub payload: Value, + pub mode: Option, + pub token: Option>, + pub proofs: Option>>, +} + +/// Signs a CALL with the caller's identity key: caller is the key's key id. +/// Refused, in this order: a key that is not an identity key, a procedure over +/// 512 bytes, a payload the wire cannot carry, a deadline or retry budget of +/// 2^53 or more, or a stream mode, which a CALL does not carry; then proofs +/// outside their bound. +pub fn sign_call(spec: &RequestSpec, key: &NodeKey) -> Result { + sign_request(RequestType::Call, spec, key) +} + +/// Signs a STREAM_OPEN, which carries `spec.mode`, with [`sign_call`]'s +/// checks, the last of them refusing no mode. +pub fn sign_stream_open(spec: &RequestSpec, key: &NodeKey) -> Result { + sign_request(RequestType::StreamOpen, spec, key) +} + +fn sign_request( + frame_type: RequestType, + spec: &RequestSpec, + key: &NodeKey, +) -> Result { + identity_signer(key)?; + bounded_text("procedure", spec.procedure.as_bytes(), MAX_PROCEDURE_BYTES)?; + check_payload(&spec.payload)?; + if spec.deadline >= MAX_PROTOCOL_INT || spec.retry_budget.is_some_and(|b| b >= MAX_PROTOCOL_INT) + { + return Err(FrameError::OutOfRange( + "a deadline or retry budget of 2^53 or more".into(), + )); + } + match (frame_type, spec.mode) { + (RequestType::Call, Some(_)) => { + return Err(FrameError::OutOfRange( + "a CALL carries no stream mode".into(), + )) + } + (RequestType::StreamOpen, None) => { + return Err(FrameError::OutOfRange( + "a STREAM_OPEN carries one of the three stream modes".into(), + )) + } + _ => {} + } + let proofs = proofs_value(&spec.proofs); + if !proofs_within_bound(&proofs) { + return Err(FrameError::ProofsOutOfBound); + } + let mut fields = vec![ + entry("frame_type", Value::text(frame_type.name())), + entry("caller", Value::Bytes(key.key_id().to_vec())), + entry("request_id", Value::Bytes(spec.request_id.to_vec())), + entry("realm", Value::Bytes(spec.realm.to_vec())), + entry("procedure", Value::text(spec.procedure.clone())), + entry("target", Value::Bytes(spec.target.to_vec())), + entry("deadline", uint(spec.deadline)), + entry("payload", spec.payload.clone()), + ]; + if let Some(mode) = spec.mode { + fields.push(entry("mode", Value::text(mode.name()))); + } + if let Some(token) = &spec.token { + fields.push(entry("token", Value::Bytes(token.clone()))); + } + if !spec.proofs.is_empty() { + fields.push(entry("proofs", proofs)); + } + let request = sign_object(REQUEST_LABEL, &fields, key).map_err(object_refusal)?; + let mut frame = vec![ + entry("version", Value::Int(i128::from(PROTOCOL_VERSION))), + entry("frame_type", Value::text(frame_type.name())), + entry("request", request.to_value()), + ]; + if let Some(route) = &spec.source_route { + frame.push(entry("source_route", Value::Bytes(route.clone()))); + } + if let Some(budget) = spec.retry_budget { + frame.push(entry("retry_budget", uint(budget))); + } + Ok(Value::Map(frame)) +} + +const REQUEST_ROUTES: &[(&str, Rule)] = &[ + ("source_route", Rule::AnyBytes), + ("retry_budget", Rule::ProtocolUint), +]; + +/// Verifies a received CALL or STREAM_OPEN under the connection's `profile`: +/// the frame's shape, the request's signature and fields, and caller as the +/// key id of its key. A station checks this before it routes, and a provider +/// before its own checks, which stay with the caller: its node_id as target, +/// the deadline window, replays and tokens. +pub fn verify_request(frame: &Value, profile: Profile) -> Result { + let (frame_type, object) = received_frame( + frame, + "request", + Rule::CarriedObject, + REQUEST_ROUTES, + &[CALL, STREAM_OPEN], + ) + .ok_or(FrameError::Malformed)?; + let frame_type = if frame_type == CALL { + RequestType::Call + } else { + RequestType::StreamOpen + }; + let verified = verify_object(REQUEST_LABEL, &object, profile).map_err(object_refusal)?; + let fields = + read_fields(&verified.fields, &request_table(frame_type)).ok_or(FrameError::Malformed)?; + let has_mode = fields.contains_key("mode"); + if !has_fields( + &fields, + &[ + "frame_type", + "caller", + "request_id", + "realm", + "procedure", + "target", + "deadline", + "payload", + ], + ) || has_mode != (frame_type == RequestType::StreamOpen) + { + return Err(FrameError::Malformed); + } + let request = VerifiedRequest { + frame_type, + request_hash: Sha384::digest(&verified.tbs).into(), + caller: fixed(&fields["caller"]), + request_id: fixed(&fields["request_id"]), + realm: fixed(&fields["realm"]), + procedure: text_of(&fields["procedure"]), + target: fixed(&fields["target"]), + deadline: protocol_uint(&fields["deadline"]).unwrap_or(0), + payload: fields["payload"].clone(), + mode: fields + .get("mode") + .and_then(|m| StreamMode::parse(&text_of(m))), + token: fields.get("token").map(super::bytes_of), + proofs: fields.get("proofs").map(|p| match p { + Value::List(items) => items.iter().map(super::bytes_of).collect(), + _ => Vec::new(), + }), + key: verified.key, + }; + if request.caller != node_id_of(&request.key, profile) { + return Err(FrameError::KeyIdMismatch); + } + Ok(request) +} + +fn request_table(frame_type: RequestType) -> Vec<(&'static str, Rule)> { + vec![ + ( + "frame_type", + Rule::TextIn(match frame_type { + RequestType::Call => &[CALL], + RequestType::StreamOpen => &[STREAM_OPEN], + }), + ), + ("alg", Rule::Any), + ("caller", Rule::BytesOf(32)), + ("request_id", Rule::BytesOf(16)), + ("realm", Rule::BytesOf(32)), + ("procedure", Rule::TextWithin(MAX_PROCEDURE_BYTES)), + ("target", Rule::BytesOf(32)), + ("deadline", Rule::ProtocolUint), + ("payload", Rule::Any), + ( + "mode", + Rule::TextIn(&["server_stream", "client_stream", "bidi"]), + ), + ("token", Rule::AnyBytes), + ("proofs", Rule::Proofs), + ] +} + +/// Whether `fields` read as a CALL's under the request table, where a +/// delegation chain's proofs are bounded: the reading the shared decoding +/// rule vectors name `request_fields`. +pub fn request_fields_accepted(fields: &Value) -> bool { + read_fields(fields, &request_table(RequestType::Call)).is_some() +} + +fn proofs_value(proofs: &[Vec]) -> Value { + Value::List(proofs.iter().map(|p| Value::Bytes(p.clone())).collect()) +} + +/// macula's bytes_set rule for proofs: a list of at most [`MAX_PROOFS`] byte +/// strings, [`MAX_PROOFS_BYTES`] in all, none repeated. +pub(super) fn proofs_within_bound(v: &Value) -> bool { + let Value::List(items) = v else { + return false; + }; + if items.len() > MAX_PROOFS { + return false; + } + let mut seen = std::collections::HashSet::with_capacity(items.len()); + let mut total = 0; + for item in items { + let Value::Bytes(b) = item else { + return false; + }; + if !seen.insert(b.as_slice()) { + return false; + } + total += b.len(); + } + total <= MAX_PROOFS_BYTES +} diff --git a/src/frame/stream.rs b/src/frame/stream.rs new file mode 100644 index 0000000..e8da719 --- /dev/null +++ b/src/frame/stream.rs @@ -0,0 +1,494 @@ +//! Stream frames (D25 item 5): STREAM_DATA, STREAM_END, STREAM_ERROR and +//! STREAM_REPLY. A provider's are signed objects under MACULA-PQ-STREAM-V1 +//! that carry the provider's key on the first frame, seq 0, and leave it out +//! after; a caller's are held objects under MACULA-PQ-CALLER-STREAM-V1, +//! verified with the key its STREAM_OPEN carried. Each side's seq runs from 0 +//! without a gap, and nothing follows a side's STREAM_END. + +use crate::cbor::Value; +use crate::node_key::{node_id_of, NodeKey}; +use crate::profile::Profile; +use crate::signed_object::{ + sign_held_object, sign_object, verify_held_object, verify_object, VerifiedObject, +}; + +use super::{ + bounded_text, check_payload, entry, fixed, has_fields, identity_signer, names_request, + object_refusal, protocol_uint, read_fields, received_frame, text_of, uint, FrameError, + RequestType, Rule, VerifiedRequest, CALLER_STREAM_LABEL, MAX_ERROR_CODE_BYTES, + MAX_ERROR_TEXT_BYTES, MAX_PROTOCOL_INT, PROTOCOL_VERSION, STREAM_LABEL, +}; + +const STREAM_DATA: &str = "stream_data"; +const STREAM_END: &str = "stream_end"; +const STREAM_ERROR: &str = "stream_error"; +const STREAM_REPLY: &str = "stream_reply"; + +/// Who pushes data on a stream: the provider (ServerStream), the caller +/// (ClientStream), or both (Bidi). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StreamMode { + ServerStream, + ClientStream, + Bidi, +} + +impl StreamMode { + pub fn name(self) -> &'static str { + match self { + StreamMode::ServerStream => "server_stream", + StreamMode::ClientStream => "client_stream", + StreamMode::Bidi => "bidi", + } + } + + pub fn parse(name: &str) -> Option { + match name { + "server_stream" => Some(StreamMode::ServerStream), + "client_stream" => Some(StreamMode::ClientStream), + "bidi" => Some(StreamMode::Bidi), + _ => None, + } + } +} + +/// How a STREAM_DATA's body reads: raw bytes, or a structured value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StreamEncoding { + Raw, + Msgpack, +} + +impl StreamEncoding { + fn name(self) -> &'static str { + match self { + StreamEncoding::Raw => "raw", + StreamEncoding::Msgpack => "msgpack", + } + } +} + +/// Which directions a STREAM_END closes: this side's sending, or both. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StreamRole { + Send, + Both, +} + +impl StreamRole { + fn name(self) -> &'static str { + match self { + StreamRole::Send => "send", + StreamRole::Both => "both", + } + } +} + +/// A stream frame's own fields, each with its sender's seq on the stream. +#[derive(Debug, Clone, PartialEq)] +pub enum StreamFields { + /// One chunk: a raw body is a byte string, a msgpack body any value the + /// wire carries. + Data { + seq: u64, + encoding: StreamEncoding, + body: Value, + }, + /// The last frame of its sender's side. + End { seq: u64, role: StreamRole }, + /// An error: a code of at most 64 bytes and a message of at most 256. + Error { + seq: u64, + code: String, + message: String, + }, + /// A provider's terminal value for a client_stream or bidi stream. + Reply { seq: u64, payload: Value }, +} + +impl StreamFields { + fn seq(&self) -> u64 { + match self { + StreamFields::Data { seq, .. } + | StreamFields::End { seq, .. } + | StreamFields::Error { seq, .. } + | StreamFields::Reply { seq, .. } => *seq, + } + } + + fn frame_type(&self) -> &'static str { + match self { + StreamFields::Data { .. } => STREAM_DATA, + StreamFields::End { .. } => STREAM_END, + StreamFields::Error { .. } => STREAM_ERROR, + StreamFields::Reply { .. } => STREAM_REPLY, + } + } +} + +/// A stream frame that verified against its stream: its signer's key id and +/// its fields. +#[derive(Debug, Clone, PartialEq)] +pub struct VerifiedStreamFrame { + pub signer: [u8; 32], + pub fields: StreamFields, +} + +/// What a verifier holds for one stream: the verified STREAM_OPEN and, for +/// each side, the next seq and whether it has ended, and for the provider the +/// key and signer its first frame carried. Each verification returns the +/// next state, which replaces this one: a state has one owner. +#[derive(Debug, Clone, PartialEq)] +pub struct StreamState { + open: VerifiedRequest, + mode: StreamMode, + provider: Side, + caller: Side, +} + +#[derive(Debug, Clone, PartialEq, Default)] +struct Side { + next: u64, + ended: bool, + key: Option>, + signer: [u8; 32], +} + +/// The state a verifier starts a stream with: nothing seen from either side +/// yet. `open` must be a verified STREAM_OPEN. +pub fn open_stream(open: &VerifiedRequest) -> Result { + match (open.frame_type, open.mode) { + (RequestType::StreamOpen, Some(mode)) => Ok(StreamState { + open: open.clone(), + mode, + provider: Side::default(), + caller: Side::default(), + }), + _ => Err(FrameError::OutOfRange( + "a stream opens on a STREAM_OPEN".into(), + )), + } +} + +/// Signs a provider's stream frame for a verified STREAM_OPEN with the +/// provider's identity key, whose key id must be the STREAM_OPEN's target. +/// The first frame, seq 0, carries the key; the later ones leave it out. +pub fn sign_provider_stream( + fields: &StreamFields, + open: &VerifiedRequest, + key: &NodeKey, +) -> Result { + let tbs = stream_build(fields, open, key, false)?; + let object = if fields.seq() == 0 { + sign_object(STREAM_LABEL, &tbs, key) + .map_err(object_refusal)? + .to_value() + } else { + sign_held_object(STREAM_LABEL, &tbs, key) + .map_err(object_refusal)? + .to_value() + }; + Ok(stream_frame(fields.frame_type(), "stream", object)) +} + +/// Signs a caller's stream frame for a verified STREAM_OPEN with the caller's +/// identity key, whose key id must be the STREAM_OPEN's caller. A caller sends +/// no STREAM_REPLY, and no STREAM_DATA in a server_stream. +pub fn sign_caller_stream( + fields: &StreamFields, + open: &VerifiedRequest, + key: &NodeKey, +) -> Result { + let tbs = stream_build(fields, open, key, true)?; + let object = sign_held_object(CALLER_STREAM_LABEL, &tbs, key).map_err(object_refusal)?; + Ok(stream_frame( + fields.frame_type(), + "caller_stream", + object.to_value(), + )) +} + +/// A stream frame build's checks in macula's order: the key against its +/// side's sender, the frame types its side sends, the text, the body or +/// payload, then the ranges. Returns the signed fields. +fn stream_build( + fields: &StreamFields, + open: &VerifiedRequest, + key: &NodeKey, + caller: bool, +) -> Result, FrameError> { + identity_signer(key)?; + let sender = if caller { open.caller } else { open.target }; + if open.frame_type != RequestType::StreamOpen || open.mode.is_none() || key.key_id() != sender { + return Err(FrameError::Unsignable); + } + if caller { + match fields { + StreamFields::Reply { .. } => { + return Err(FrameError::NotAllowed("a caller's STREAM_REPLY".into())) + } + StreamFields::Data { .. } if open.mode == Some(StreamMode::ServerStream) => { + return Err(FrameError::NotAllowed( + "a caller's STREAM_DATA in a server_stream".into(), + )) + } + _ => {} + } + } + if let StreamFields::Error { code, message, .. } = fields { + bounded_text("code", code.as_bytes(), MAX_ERROR_CODE_BYTES)?; + bounded_text("message", message.as_bytes(), MAX_ERROR_TEXT_BYTES)?; + } + match fields { + StreamFields::Data { + encoding: StreamEncoding::Msgpack, + body, + .. + } => check_payload(body)?, + StreamFields::Reply { payload, .. } => check_payload(payload)?, + _ => {} + } + let mut tbs = match fields { + StreamFields::Data { encoding, body, .. } => { + if *encoding == StreamEncoding::Raw && !matches!(body, Value::Bytes(_)) { + return Err(FrameError::OutOfRange( + "a raw body that is not a byte string".into(), + )); + } + vec![ + entry("encoding", Value::text(encoding.name())), + entry("body", body.clone()), + ] + } + StreamFields::End { role, .. } => vec![entry("role", Value::text(role.name()))], + StreamFields::Error { code, message, .. } => { + vec![ + entry("code", Value::text(code.clone())), + entry("message", Value::text(message.clone())), + ] + } + StreamFields::Reply { payload, .. } => vec![entry("payload", payload.clone())], + }; + if fields.seq() >= MAX_PROTOCOL_INT { + return Err(FrameError::OutOfRange("a seq of 2^53 or more".into())); + } + tbs.extend([ + entry("frame_type", Value::text(fields.frame_type())), + entry("request_id", Value::Bytes(open.request_id.to_vec())), + entry("request_hash", Value::Bytes(open.request_hash.to_vec())), + entry("signer", Value::Bytes(key.key_id().to_vec())), + entry("seq", uint(fields.seq())), + ]); + Ok(tbs) +} + +fn stream_frame(frame_type: &str, object_name: &str, object: Value) -> Value { + Value::Map(vec![ + entry("version", Value::Int(i128::from(PROTOCOL_VERSION))), + entry("frame_type", Value::text(frame_type)), + entry(object_name, object), + ]) +} + +const PROVIDER_TYPES: &[&str] = &[STREAM_DATA, STREAM_END, STREAM_ERROR, STREAM_REPLY]; +const CALLER_TYPES: &[&str] = &[STREAM_DATA, STREAM_END, STREAM_ERROR]; + +/// Verifies a provider's received stream frame against its stream's state, +/// and returns the frame and the stream's next state. Before the provider's +/// first frame the state holds no provider key, so a frame without one is out +/// of order. The first frame's signer is the key id of the key it carries and +/// the STREAM_OPEN's target, with seq 0; later frames verify with that key, +/// name that signer and carry no key. +pub fn verify_provider_stream( + frame: &Value, + state: &StreamState, + profile: Profile, +) -> Result<(VerifiedStreamFrame, StreamState), FrameError> { + let (frame_type, object) = + received_frame(frame, "stream", Rule::StreamObject, &[], PROVIDER_TYPES) + .ok_or(FrameError::Malformed)?; + if state.provider.ended { + return Err(FrameError::StreamEnded); + } + let carries_key = object.get("key").is_some(); + let Some(held_key) = &state.provider.key else { + return provider_first(&frame_type, &object, carries_key, state, profile); + }; + let verified = if carries_key { + verify_object(STREAM_LABEL, &object, profile) + } else { + verify_held_object(STREAM_LABEL, &object, held_key, profile) + } + .map_err(object_refusal)?; + if &verified.key != held_key { + return Err(FrameError::KeyIdMismatch); + } + let (signer, fields, read) = stream_read(&frame_type, &verified)?; + if signer != state.provider.signer { + return Err(FrameError::KeyIdMismatch); + } + if !names_request(&read, &state.open) { + return Err(FrameError::RequestMismatch); + } + if fields.seq() != state.provider.next { + return Err(FrameError::SeqMismatch); + } + if carries_key { + return Err(FrameError::Malformed); + } + let mut next = state.clone(); + next.provider.next = fields.seq() + 1; + next.provider.ended = frame_type == STREAM_END; + Ok((VerifiedStreamFrame { signer, fields }, next)) +} + +fn provider_first( + frame_type: &str, + object: &Value, + carries_key: bool, + state: &StreamState, + profile: Profile, +) -> Result<(VerifiedStreamFrame, StreamState), FrameError> { + if !carries_key { + return Err(FrameError::SeqMismatch); + } + let verified = verify_object(STREAM_LABEL, object, profile).map_err(object_refusal)?; + let (signer, fields, read) = stream_read(frame_type, &verified)?; + if signer != node_id_of(&verified.key, profile) { + return Err(FrameError::KeyIdMismatch); + } + if !names_request(&read, &state.open) { + return Err(FrameError::RequestMismatch); + } + if signer != state.open.target { + return Err(FrameError::NotTheTarget); + } + if fields.seq() != 0 { + return Err(FrameError::SeqMismatch); + } + let mut next = state.clone(); + next.provider = Side { + next: 1, + ended: frame_type == STREAM_END, + key: Some(verified.key), + signer, + }; + Ok((VerifiedStreamFrame { signer, fields }, next)) +} + +/// Verifies a caller's received stream frame against its stream's state, +/// with the STREAM_OPEN's key, and returns the frame and the next state. A +/// caller sends no STREAM_DATA in a server_stream. +pub fn verify_caller_stream( + frame: &Value, + state: &StreamState, + profile: Profile, +) -> Result<(VerifiedStreamFrame, StreamState), FrameError> { + let (frame_type, object) = + received_frame(frame, "caller_stream", Rule::HeldObject, &[], CALLER_TYPES) + .ok_or(FrameError::Malformed)?; + if state.caller.ended { + return Err(FrameError::StreamEnded); + } + let verified = verify_held_object(CALLER_STREAM_LABEL, &object, &state.open.key, profile) + .map_err(object_refusal)?; + let (signer, fields, read) = stream_read(&frame_type, &verified)?; + if frame_type == STREAM_DATA && state.mode == StreamMode::ServerStream { + return Err(FrameError::Malformed); + } + if signer != state.open.caller { + return Err(FrameError::KeyIdMismatch); + } + if !names_request(&read, &state.open) { + return Err(FrameError::RequestMismatch); + } + if fields.seq() != state.caller.next { + return Err(FrameError::SeqMismatch); + } + let mut next = state.clone(); + next.caller.next = fields.seq() + 1; + next.caller.ended = frame_type == STREAM_END; + Ok((VerifiedStreamFrame { signer, fields }, next)) +} + +/// A stream frame's signed fields read through its type's table: frame_type, +/// request_id, request_hash, signer and seq, and exactly the fields of its +/// type, a raw body a byte string. +fn stream_read( + frame_type: &str, + verified: &VerifiedObject, +) -> Result<([u8; 32], StreamFields, super::Fields), FrameError> { + let types: &'static [&'static str] = match frame_type { + STREAM_DATA => &[STREAM_DATA], + STREAM_END => &[STREAM_END], + STREAM_ERROR => &[STREAM_ERROR], + _ => &[STREAM_REPLY], + }; + let table = [ + ("frame_type", Rule::TextIn(types)), + ("alg", Rule::Any), + ("request_id", Rule::BytesOf(16)), + ("request_hash", Rule::BytesOf(48)), + ("signer", Rule::BytesOf(32)), + ("seq", Rule::ProtocolUint), + ("encoding", Rule::TextIn(&["raw", "msgpack"])), + ("body", Rule::Any), + ("role", Rule::TextIn(&["send", "both"])), + ("code", Rule::TextWithin(MAX_ERROR_CODE_BYTES)), + ("message", Rule::TextWithin(MAX_ERROR_TEXT_BYTES)), + ("payload", Rule::Any), + ]; + let fields = read_fields(&verified.fields, &table).ok_or(FrameError::Malformed)?; + if !has_fields( + &fields, + &["frame_type", "request_id", "request_hash", "signer", "seq"], + ) { + return Err(FrameError::Malformed); + } + let own: &[&str] = match frame_type { + STREAM_DATA => &["encoding", "body"], + STREAM_END => &["role"], + STREAM_ERROR => &["code", "message"], + _ => &["payload"], + }; + let carried = 5 + own.len() + usize::from(fields.contains_key("alg")); + if !has_fields(&fields, own) || fields.len() != carried { + return Err(FrameError::Malformed); + } + let seq = protocol_uint(&fields["seq"]).unwrap_or(0); + let parsed = match frame_type { + STREAM_DATA => { + let encoding = if text_of(&fields["encoding"]) == "raw" { + StreamEncoding::Raw + } else { + StreamEncoding::Msgpack + }; + if encoding == StreamEncoding::Raw && !matches!(fields["body"], Value::Bytes(_)) { + return Err(FrameError::Malformed); + } + StreamFields::Data { + seq, + encoding, + body: fields["body"].clone(), + } + } + STREAM_END => StreamFields::End { + seq, + role: if text_of(&fields["role"]) == "send" { + StreamRole::Send + } else { + StreamRole::Both + }, + }, + STREAM_ERROR => StreamFields::Error { + seq, + code: text_of(&fields["code"]), + message: text_of(&fields["message"]), + }, + _ => StreamFields::Reply { + seq, + payload: fields["payload"].clone(), + }, + }; + Ok((fixed(&fields["signer"]), parsed, fields)) +} diff --git a/src/lib.rs b/src/lib.rs index c13a0ab..736e1d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod binding; pub mod cbor; +pub mod frame; pub mod handshake; pub mod keystore; pub mod node_key; diff --git a/tests/frame_codec.rs b/tests/frame_codec.rs new file mode 100644 index 0000000..7dca38b --- /dev/null +++ b/tests/frame_codec.rs @@ -0,0 +1,104 @@ +//! The length-prefixed wire codec, and the payload and frame checks a sender +//! runs so nothing the decoding rule refuses on arrival leaves this node. + +use macula_rust::cbor::Value; +use macula_rust::frame::{ + check_frame, check_payload, decode, encode, Decoded, FrameError, MAX_FRAME_BYTES, + MAX_PAYLOAD_ELEMENTS, MAX_PAYLOAD_NESTING, +}; + +fn nested(depth: usize) -> Value { + (0..depth).fold(Value::Int(0), |inner, _| Value::List(vec![inner])) +} + +#[test] +fn a_frame_travels_length_prefixed_and_decodes_whole() { + let frame = Value::Map(vec![(Value::text("version"), Value::Int(2))]); + let wire = encode(&frame).unwrap(); + assert_eq!(&wire[..4], &((wire.len() - 4) as u32).to_be_bytes()); + let mut two = wire.clone(); + two.extend_from_slice(&wire); + match decode(&two).unwrap() { + Decoded::Complete { + frame: decoded, + consumed, + } => { + assert_eq!(decoded, frame); + assert_eq!(consumed, wire.len()); + } + other => panic!("{other:?}"), + } + assert_eq!(decode(&wire[..2]).unwrap(), Decoded::NeedMore(2)); + assert_eq!( + decode(&wire[..wire.len() - 1]).unwrap(), + Decoded::NeedMore(1) + ); +} + +#[test] +fn a_frame_over_the_cap_is_refused_both_ways() { + let big = Value::Bytes(vec![0u8; MAX_FRAME_BYTES]); + assert!(matches!(encode(&big), Err(FrameError::TooLarge(_)))); + let mut header = ((MAX_FRAME_BYTES + 1) as u32).to_be_bytes().to_vec(); + header.push(0); + assert!(matches!(decode(&header), Err(FrameError::TooLarge(_)))); + assert!(matches!( + decode(&[0, 0, 0, 1, 0xff]), + Err(FrameError::Malformed) + )); +} + +#[test] +fn a_payload_the_decoding_rule_would_refuse_is_refused_before_it_is_sent() { + let refused = [ + ( + "a float key", + Value::Map(vec![(Value::Float(1.0), Value::Int(1))]), + ), + ( + "a bytes key", + Value::Map(vec![(Value::Bytes(vec![1]), Value::Int(1))]), + ), + ( + "two keys that encode alike", + Value::Map(vec![ + (Value::text("a"), Value::Int(1)), + (Value::text("a"), Value::Int(2)), + ]), + ), + ( + "an integer above 2^63-1", + Value::Int(i128::from(i64::MAX) + 1), + ), + ("a NaN", Value::Float(f64::NAN)), + ("an infinity", Value::Float(f64::INFINITY)), + ("too deep", nested(MAX_PAYLOAD_NESTING + 1)), + ( + "too many items", + Value::List(vec![Value::Int(0); MAX_PAYLOAD_ELEMENTS]), + ), + ]; + for (name, payload) in refused { + assert!( + matches!(check_payload(&payload), Err(FrameError::Payload(_))), + "{name}" + ); + } + assert!(check_payload(&nested(MAX_PAYLOAD_NESTING)).is_ok()); + assert!(check_payload(&Value::List(vec![Value::Int(0); MAX_PAYLOAD_ELEMENTS - 1])).is_ok()); + assert!(check_payload(&Value::Map(vec![(Value::Int(-1), Value::text("x"))])).is_ok()); +} + +#[test] +fn a_whole_frame_is_held_to_the_rule_s_own_limits() { + let frame = Value::Map(vec![(Value::text("payload"), nested(MAX_PAYLOAD_NESTING))]); + assert!(check_frame(&frame).is_ok()); + let deeper = Value::Map(vec![( + Value::text("payload"), + nested(MAX_PAYLOAD_NESTING + 1), + )]); + assert!(matches!( + check_frame(&deeper), + Err(FrameError::BreaksDecodingRule(_)) + )); +} diff --git a/tests/frame_publication_neighbour.rs b/tests/frame_publication_neighbour.rs new file mode 100644 index 0000000..b06e337 --- /dev/null +++ b/tests/frame_publication_neighbour.rs @@ -0,0 +1,240 @@ +//! Publications (D17), signed by their publishers and held to their time, and +//! the control frames a pq_hybrid link neighbour-signs, held to the ones macula +//! itself signed (tests/vectors/frame/erlang_neighbour.json, macula_frame at +//! macula v12.1.0). + +use macula_rust::cbor::{self, Value}; +use macula_rust::frame::{ + advertise_frame, decode, goodbye_frame, neighbour_signed, sign_neighbour, sign_publish, + subscribe_frame, unadvertise_frame, unsubscribe_frame, verify_neighbour, verify_publication, + Decoded, FrameError, NeighbourLink, NeighbourPeer, PublicationSpec, +}; +use macula_rust::node_key::{NodeKey, Purpose}; +use macula_rust::profile::Profile; + +const NOW: u64 = 1_789_000_000_000; +const MINUTE: u64 = 60_000; + +fn spec() -> PublicationSpec { + PublicationSpec { + realm: [3; 32], + topic: "acme/demo/greeting_sent_v1".to_string(), + seq: 1, + published_at: NOW, + payload: Value::text("hi"), + ttl_ms: None, + } +} + +fn arrived(frame: &Value) -> Value { + cbor::decode(&cbor::encode(frame).unwrap()).unwrap() +} + +#[test] +fn a_publication_verifies_for_its_publisher_within_its_time() { + for profile in [Profile::PqPure, Profile::PqHybrid] { + let key = NodeKey::generate(Purpose::Identity, profile).unwrap(); + let frame = arrived(&sign_publish(&spec(), &key).unwrap()); + let publication = verify_publication(&frame, profile, NOW as i64).unwrap(); + assert_eq!(publication.publisher, key.key_id()); + assert_eq!(publication.topic, "acme/demo/greeting_sent_v1"); + assert_eq!(publication.seq, 1); + assert_eq!(publication.payload, Value::text("hi")); + assert_eq!(publication.expires_at, NOW + 15 * MINUTE); + + assert_eq!( + verify_publication(&frame, profile, (NOW - 6 * MINUTE) as i64).unwrap_err(), + FrameError::NotYetValid(MINUTE as i64) + ); + assert_eq!( + verify_publication(&frame, profile, (NOW + 16 * MINUTE) as i64).unwrap_err(), + FrameError::Expired(MINUTE as i64) + ); + } +} + +#[test] +fn a_publication_s_ttl_bounds_its_life_to_an_hour() { + let key = NodeKey::generate(Purpose::Identity, Profile::PqPure).unwrap(); + let mut long = spec(); + long.ttl_ms = Some(60 * MINUTE); + let frame = arrived(&sign_publish(&long, &key).unwrap()); + assert_eq!( + verify_publication(&frame, Profile::PqPure, NOW as i64) + .unwrap() + .expires_at, + NOW + 65 * MINUTE + ); + long.ttl_ms = Some(60 * MINUTE + 1); + assert!(matches!( + sign_publish(&long, &key), + Err(FrameError::OutOfRange(_)) + )); + let mut wide = spec(); + wide.topic = "t".repeat(513); + assert!(matches!( + sign_publish(&wide, &key), + Err(FrameError::TextTooLong(_)) + )); +} + +#[test] +fn an_event_carries_the_publication_with_how_it_was_delivered() { + let key = NodeKey::generate(Purpose::Identity, Profile::PqPure).unwrap(); + let Value::Map(pairs) = sign_publish(&spec(), &key).unwrap() else { + unreachable!() + }; + let as_event = |extra: Option<(Value, Value)>| { + let mut event: Vec<(Value, Value)> = pairs + .iter() + .map(|(k, v)| { + if *k == Value::text("frame_type") { + (k.clone(), Value::text("event")) + } else { + (k.clone(), v.clone()) + } + }) + .collect(); + event.extend(extra); + Value::Map(event) + }; + let direct = as_event(Some((Value::text("delivered_via"), Value::text("direct")))); + assert!(verify_publication(&direct, Profile::PqPure, NOW as i64).is_ok()); + assert_eq!( + verify_publication(&as_event(None), Profile::PqPure, NOW as i64).unwrap_err(), + FrameError::Malformed + ); +} + +fn unhex(s: &str) -> Vec { + hex::decode(s).unwrap() +} + +fn whole(bytes: &[u8]) -> Value { + match decode(bytes).unwrap() { + Decoded::Complete { frame, consumed } if consumed == bytes.len() => frame, + other => panic!("{other:?}"), + } +} + +fn frame_type(v: &Value) -> Option<&str> { + match v.get("frame_type") { + Some(Value::Text(t)) => Some(t), + _ => None, + } +} + +#[test] +fn control_frames_macula_signed_open_here() { + let text = std::fs::read_to_string("tests/vectors/frame/erlang_neighbour.json").unwrap(); + let doc: serde_json::Value = serde_json::from_str(&text).unwrap(); + let entries = doc["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + let mut opened_count = 0; + for e in entries { + let profile = Profile::parse(e["profile"].as_str().unwrap()).unwrap(); + let connection: [u8; 48] = unhex(e["connection"].as_str().unwrap()).try_into().unwrap(); + let peer_key = unhex(e["peer_key"].as_str().unwrap()); + for f in e["frames"].as_array().unwrap() { + let wire = whole(&unhex(f["bytes"].as_str().unwrap())); + let peer = NeighbourPeer { + profile, + peer_key: peer_key.clone(), + connection, + seq: f["seq"].as_u64().unwrap(), + }; + let opened = verify_neighbour(&wire, &peer).unwrap(); + assert_eq!(frame_type(&opened), f["frame_type"].as_str(), "{profile:?}"); + opened_count += 1; + if profile != Profile::PqHybrid { + continue; + } + let next = NeighbourPeer { + seq: peer.seq + 1, + ..peer.clone() + }; + assert_eq!( + verify_neighbour(&wire, &next).unwrap_err(), + FrameError::Malformed + ); + let mut other = peer.clone(); + other.connection[0] ^= 1; + assert_eq!( + verify_neighbour(&wire, &other).unwrap_err(), + FrameError::Malformed + ); + } + } + assert!(opened_count >= 10); +} + +#[test] +fn control_frames_built_here_open_again_and_only_pq_hybrid_signs_them() { + let realm = [3u8; 32]; + let subscriber = [1u8; 32]; + let topic = b"io.macula/mcl-news/news/wire/news_item_reported_v1"; + for profile in [Profile::PqHybrid, Profile::PqPure] { + let key = NodeKey::generate(Purpose::Identity, profile).unwrap(); + let connection = [9u8; 48]; + let frames = [ + ("advertise", advertise_frame(b"a signed record")), + ("unadvertise", unadvertise_frame(b"a signed withdrawal")), + ( + "subscribe", + subscribe_frame(topic, &realm, &subscriber).unwrap(), + ), + ( + "unsubscribe", + unsubscribe_frame(topic, &realm, &subscriber).unwrap(), + ), + ( + "goodbye", + goodbye_frame("normal", Some(b"closing")).unwrap(), + ), + ]; + for (seq, (name, frame)) in frames.into_iter().enumerate() { + assert_eq!( + neighbour_signed(profile, name), + profile == Profile::PqHybrid + ); + let link = NeighbourLink { + connection, + seq: seq as u64, + }; + let signed = sign_neighbour(&frame, &key, &link).unwrap(); + assert_eq!( + signed.get("neighbour").is_some(), + profile == Profile::PqHybrid, + "{name}" + ); + let peer = NeighbourPeer { + profile, + peer_key: key.public_key(), + connection, + seq: seq as u64, + }; + let opened = verify_neighbour(&arrived(&signed), &peer).unwrap(); + assert_eq!(frame_type(&opened), Some(name)); + if profile == Profile::PqHybrid { + assert_eq!( + sign_neighbour(&signed, &key, &link).unwrap_err(), + FrameError::NeighbourSigned + ); + } + } + } +} + +#[test] +fn a_subscribe_topic_is_utf8_of_at_most_512_bytes() { + let (realm, subscriber) = ([1u8; 32], [2u8; 32]); + assert!(subscribe_frame(&[b't'; 512], &realm, &subscriber).is_ok()); + assert!(matches!( + subscribe_frame(&[b't'; 513], &realm, &subscriber), + Err(FrameError::TextTooLong(_)) + )); + assert!(matches!( + unsubscribe_frame(b"\xff", &realm, &subscriber), + Err(FrameError::InvalidText(_)) + )); +} diff --git a/tests/frame_request_reply.rs b/tests/frame_request_reply.rs new file mode 100644 index 0000000..a978179 --- /dev/null +++ b/tests/frame_request_reply.rs @@ -0,0 +1,335 @@ +//! Requests, replies and relay errors (D25): signed by their senders' identity +//! keys, verified by who they claim to be from and which request they answer, +//! and every build refused where macula refuses it. + +use macula_rust::cbor::{self, Value}; +use macula_rust::frame::{ + claimed_reply_ids, sign_call, sign_provider_error, sign_relay_error, sign_result, + sign_stream_open, verify_relay_error, verify_reply, verify_request, FrameError, RelayErrorSpec, + RelayErrorType, ReplyType, RequestSpec, RequestType, StreamMode, VerifiedRequest, MAX_PROOFS, + MAX_PROOFS_BYTES, +}; +use macula_rust::node_key::{NodeKey, Purpose}; +use macula_rust::profile::Profile; + +struct Keys { + caller: NodeKey, + provider: NodeKey, + station: NodeKey, +} + +fn keys(profile: Profile) -> Keys { + Keys { + caller: NodeKey::generate(Purpose::Identity, profile).unwrap(), + provider: NodeKey::generate(Purpose::Identity, profile).unwrap(), + station: NodeKey::generate(Purpose::Identity, profile).unwrap(), + } +} + +fn call_spec(keys: &Keys) -> RequestSpec { + RequestSpec { + request_id: [7; 16], + realm: [3; 32], + procedure: "acme/echo".to_string(), + target: keys.provider.key_id(), + deadline: 1_789_000_005_000, + payload: Value::text("hello"), + mode: None, + token: None, + proofs: Vec::new(), + source_route: None, + retry_budget: None, + } +} + +/// A frame as it arrives: encoded and decoded under the decoding rule. +fn arrived(frame: &Value) -> Value { + cbor::decode(&cbor::encode(frame).unwrap()).unwrap() +} + +fn verified_call(keys: &Keys, spec: &RequestSpec, profile: Profile) -> VerifiedRequest { + verify_request(&arrived(&sign_call(spec, &keys.caller).unwrap()), profile).unwrap() +} + +#[test] +fn a_call_verifies_as_its_caller_signed_it() { + for profile in [Profile::PqPure, Profile::PqHybrid] { + let k = keys(profile); + let mut spec = call_spec(&k); + spec.token = Some(b"a token".to_vec()); + spec.source_route = Some(b"route".to_vec()); + spec.retry_budget = Some(3); + let request = verified_call(&k, &spec, profile); + assert_eq!(request.frame_type, RequestType::Call); + assert_eq!(request.caller, k.caller.key_id()); + assert_eq!(request.key, k.caller.public_key()); + assert_eq!(request.request_id, spec.request_id); + assert_eq!(request.realm, spec.realm); + assert_eq!(request.procedure, "acme/echo"); + assert_eq!(request.target, spec.target); + assert_eq!(request.deadline, spec.deadline); + assert_eq!(request.payload, Value::text("hello")); + assert_eq!(request.mode, None); + assert_eq!(request.token.as_deref(), Some(&b"a token"[..])); + assert_eq!(request.proofs, None); + } +} + +#[test] +fn a_stream_open_carries_its_mode() { + let k = keys(Profile::PqPure); + let mut spec = call_spec(&k); + spec.mode = Some(StreamMode::Bidi); + let request = verify_request( + &arrived(&sign_stream_open(&spec, &k.caller).unwrap()), + Profile::PqPure, + ) + .unwrap(); + assert_eq!(request.frame_type, RequestType::StreamOpen); + assert_eq!(request.mode, Some(StreamMode::Bidi)); + spec.mode = None; + assert!(matches!( + sign_stream_open(&spec, &k.caller), + Err(FrameError::OutOfRange(_)) + )); + spec.mode = Some(StreamMode::ServerStream); + assert!(matches!( + sign_call(&spec, &k.caller), + Err(FrameError::OutOfRange(_)) + )); +} + +#[test] +fn a_request_build_is_refused_in_macula_s_order() { + let k = keys(Profile::PqPure); + let connect = NodeKey::generate(Purpose::Connect, Profile::PqPure).unwrap(); + assert_eq!( + sign_call(&call_spec(&k), &connect).unwrap_err(), + FrameError::Unsignable + ); + let mut long = call_spec(&k); + long.procedure = "p".repeat(513); + assert!(matches!( + sign_call(&long, &k.caller), + Err(FrameError::TextTooLong(_)) + )); + let mut unsendable = call_spec(&k); + unsendable.payload = Value::Float(f64::NAN); + assert!(matches!( + sign_call(&unsendable, &k.caller), + Err(FrameError::Payload(_)) + )); + let mut late = call_spec(&k); + late.deadline = 1 << 53; + assert!(matches!( + sign_call(&late, &k.caller), + Err(FrameError::OutOfRange(_)) + )); +} + +#[test] +fn a_request_carries_its_proofs_within_their_bound() { + let k = keys(Profile::PqPure); + let mut spec = call_spec(&k); + spec.proofs = vec![b"proof.one".to_vec(), b"proof.two".to_vec()]; + assert_eq!( + verified_call(&k, &spec, Profile::PqPure).proofs, + Some(spec.proofs.clone()) + ); + for (name, proofs) in [ + ( + "nine", + (0..=MAX_PROOFS as u8).map(|i| vec![i]).collect::>(), + ), + ("one repeated", vec![b"same".to_vec(), b"same".to_vec()]), + ("over the bytes", vec![vec![0u8; MAX_PROOFS_BYTES + 1]]), + ] { + spec.proofs = proofs; + assert_eq!( + sign_call(&spec, &k.caller).unwrap_err(), + FrameError::ProofsOutOfBound, + "{name}" + ); + } +} + +/// The shared decoding rule vectors macula reads as a CALL's fields: where a +/// delegation chain's proofs are bounded. +#[test] +fn the_request_field_vectors_read_as_every_stack_reads_them() { + let text = std::fs::read_to_string("tests/vectors/cbor/decoding_rule_v1.json").unwrap(); + let doc: serde_json::Value = serde_json::from_str(&text).unwrap(); + let mut ran = 0; + for e in doc["entries"].as_array().unwrap() { + if e["via"].as_str() != Some("request_fields") { + continue; + } + ran += 1; + let fields = cbor::decode(&hex::decode(e["cbor"].as_str().unwrap()).unwrap()).unwrap(); + let accepted = macula_rust::frame::request_fields_accepted(&fields); + assert_eq!(accepted, e["expect"] == "accept", "{}", e["name"]); + } + assert_eq!(ran, 5); +} + +#[test] +fn a_request_altered_or_from_another_caller_is_refused() { + let k = keys(Profile::PqPure); + let frame = arrived(&sign_call(&call_spec(&k), &k.caller).unwrap()); + // One tbs byte changed. + let altered = rewrite_object(&frame, "request", |object| { + if let Some(Value::Bytes(tbs)) = object + .iter_mut() + .find(|(k, _)| *k == Value::text("tbs")) + .map(|(_, v)| v) + { + let at = tbs.len() / 2; + tbs[at] ^= 1; + } + }); + assert_eq!( + verify_request(&altered, Profile::PqPure).unwrap_err(), + FrameError::SignatureInvalid + ); + // Another key carried in place of the caller's. + let swapped = rewrite_object(&frame, "request", |object| { + for (k2, v) in object.iter_mut() { + if *k2 == Value::text("key") { + *v = Value::Bytes(k.station.public_key()); + } + } + }); + assert_eq!( + verify_request(&swapped, Profile::PqPure).unwrap_err(), + FrameError::SignatureInvalid + ); + // An extra field on the frame. + let extra = match frame.clone() { + Value::Map(mut pairs) => { + pairs.push((Value::text("more"), Value::Int(1))); + Value::Map(pairs) + } + _ => unreachable!(), + }; + assert_eq!( + verify_request(&extra, Profile::PqPure).unwrap_err(), + FrameError::Malformed + ); +} + +fn rewrite_object(frame: &Value, name: &str, mut f: impl FnMut(&mut Vec<(Value, Value)>)) -> Value { + let Value::Map(mut pairs) = frame.clone() else { + unreachable!() + }; + for (k, v) in pairs.iter_mut() { + if *k == Value::text(name) { + if let Value::Map(object) = v { + f(object); + } + } + } + Value::Map(pairs) +} + +#[test] +fn a_reply_verifies_only_from_the_target_for_its_request() { + for profile in [Profile::PqPure, Profile::PqHybrid] { + let k = keys(profile); + let request = verified_call(&k, &call_spec(&k), profile); + + let result = + arrived(&sign_result(&request, &Value::text("pong"), None, &k.provider).unwrap()); + let reply = verify_reply(&result, &request, profile).unwrap(); + assert_eq!(reply.frame_type, ReplyType::Result); + assert_eq!(reply.responded_by, k.provider.key_id()); + assert_eq!(reply.payload, Some(Value::text("pong"))); + + let error = arrived( + &sign_provider_error( + &request, + "handler_error", + Some("refused"), + Some(b"back".to_vec()), + &k.provider, + ) + .unwrap(), + ); + let reply = verify_reply(&error, &request, profile).unwrap(); + assert_eq!(reply.frame_type, ReplyType::Error); + assert_eq!(reply.code.as_deref(), Some("handler_error")); + assert_eq!(reply.detail.as_deref(), Some("refused")); + + // Signed by anyone but the target: not signable, and not accepted. + assert_eq!( + sign_result(&request, &Value::Null, None, &k.station).unwrap_err(), + FrameError::Unsignable + ); + let mut other_target = call_spec(&k); + other_target.target = k.station.key_id(); + let other_request = verified_call(&k, &other_target, profile); + let from_station = + arrived(&sign_result(&other_request, &Value::Null, None, &k.station).unwrap()); + assert_eq!( + verify_reply(&from_station, &request, profile).unwrap_err(), + FrameError::RequestMismatch + ); + let mut same_ids = request.clone(); + same_ids.target = k.station.key_id(); + let as_other = arrived(&sign_result(&same_ids, &Value::Null, None, &k.station).unwrap()); + assert_eq!( + verify_reply(&as_other, &request, profile).unwrap_err(), + FrameError::NotTheTarget + ); + + assert_eq!( + claimed_reply_ids(&result).unwrap(), + (request.request_id, request.request_hash) + ); + } +} + +#[test] +fn a_provider_error_s_text_is_bounded() { + let k = keys(Profile::PqPure); + let request = verified_call(&k, &call_spec(&k), Profile::PqPure); + assert!(matches!( + sign_provider_error(&request, &"c".repeat(65), None, None, &k.provider), + Err(FrameError::TextTooLong(_)) + )); + assert!(matches!( + sign_provider_error(&request, "code", Some(&"d".repeat(257)), None, &k.provider), + Err(FrameError::TextTooLong(_)) + )); +} + +#[test] +fn a_relay_error_verifies_only_from_the_connection_s_station() { + let k = keys(Profile::PqPure); + let request = verified_call(&k, &call_spec(&k), Profile::PqPure); + let spec = RelayErrorSpec { + frame_type: RelayErrorType::Error, + request: request.clone(), + code: "unknown_next_peer".to_string(), + offending_hop: Some([5; 32]), + source_route_partial: None, + }; + let frame = arrived(&sign_relay_error(&spec, &k.station).unwrap()); + let relay = verify_relay_error(&frame, &request, Profile::PqPure, &k.station.key_id()).unwrap(); + assert_eq!(relay.reported_by, k.station.key_id()); + assert_eq!(relay.code, "unknown_next_peer"); + assert_eq!(relay.offending_hop, Some([5; 32])); + assert_eq!( + verify_relay_error(&frame, &request, Profile::PqPure, &[1; 32]).unwrap_err(), + FrameError::NotTheConnection + ); + assert_eq!( + claimed_reply_ids(&frame).unwrap(), + (request.request_id, request.request_hash) + ); + let mut outside = spec.clone(); + outside.code = "handler_error".to_string(); + assert_eq!( + sign_relay_error(&outside, &k.station).unwrap_err(), + FrameError::RelayCodeOutsideItsSet + ); +} diff --git a/tests/frame_stream.rs b/tests/frame_stream.rs new file mode 100644 index 0000000..16d0d15 --- /dev/null +++ b/tests/frame_stream.rs @@ -0,0 +1,202 @@ +//! Stream frames (D25 item 5): the provider's carry its key on its first frame +//! and not after, the caller's are verified with the key its STREAM_OPEN +//! carried, each side's seq runs from 0 without a gap, and nothing follows a +//! side's STREAM_END. + +use macula_rust::cbor::{self, Value}; +use macula_rust::frame::{ + open_stream, sign_caller_stream, sign_provider_stream, sign_stream_open, verify_caller_stream, + verify_provider_stream, verify_request, FrameError, RequestSpec, StreamEncoding, StreamFields, + StreamMode, StreamRole, StreamState, VerifiedRequest, +}; +use macula_rust::node_key::{NodeKey, Purpose}; +use macula_rust::profile::Profile; + +const P: Profile = Profile::PqPure; + +struct Stream { + caller: NodeKey, + provider: NodeKey, + open: VerifiedRequest, +} + +fn stream(mode: StreamMode) -> Stream { + let caller = NodeKey::generate(Purpose::Identity, P).unwrap(); + let provider = NodeKey::generate(Purpose::Identity, P).unwrap(); + let spec = RequestSpec { + request_id: [4; 16], + realm: [3; 32], + procedure: "acme/watch".to_string(), + target: provider.key_id(), + deadline: 1_789_000_005_000, + payload: Value::Null, + mode: Some(mode), + token: None, + proofs: Vec::new(), + source_route: None, + retry_budget: None, + }; + let frame = sign_stream_open(&spec, &caller).unwrap(); + let open = verify_request(&arrived(&frame), P).unwrap(); + Stream { + caller, + provider, + open, + } +} + +fn arrived(frame: &Value) -> Value { + cbor::decode(&cbor::encode(frame).unwrap()).unwrap() +} + +fn data(seq: u64, body: &[u8]) -> StreamFields { + StreamFields::Data { + seq, + encoding: StreamEncoding::Raw, + body: Value::Bytes(body.to_vec()), + } +} + +#[test] +fn a_provider_s_frames_carry_its_key_first_and_run_in_order() { + let s = stream(StreamMode::ServerStream); + let mut state = open_stream(&s.open).unwrap(); + let first = arrived(&sign_provider_stream(&data(0, b"one"), &s.open, &s.provider).unwrap()); + assert!(first.get("stream").and_then(|o| o.get("key")).is_some()); + let later = arrived(&sign_provider_stream(&data(1, b"two"), &s.open, &s.provider).unwrap()); + assert!(later.get("stream").and_then(|o| o.get("key")).is_none()); + let end = arrived( + &sign_provider_stream( + &StreamFields::End { + seq: 2, + role: StreamRole::Both, + }, + &s.open, + &s.provider, + ) + .unwrap(), + ); + + // The later frame before the first: no key held yet. + assert_eq!( + verify_provider_stream(&later, &state, P).unwrap_err(), + FrameError::SeqMismatch + ); + for (frame, want) in [(&first, data(0, b"one")), (&later, data(1, b"two"))] { + let (verified, next) = verify_provider_stream(frame, &state, P).unwrap(); + assert_eq!(verified.signer, s.provider.key_id()); + assert_eq!(verified.fields, want); + state = next; + } + // The same frame again: out of order. + assert_eq!( + verify_provider_stream(&later, &state, P).unwrap_err(), + FrameError::SeqMismatch + ); + let (_, ended) = verify_provider_stream(&end, &state, P).unwrap(); + let after = arrived(&sign_provider_stream(&data(3, b"late"), &s.open, &s.provider).unwrap()); + assert_eq!( + verify_provider_stream(&after, &ended, P).unwrap_err(), + FrameError::StreamEnded + ); +} + +#[test] +fn a_caller_s_frames_verify_with_the_stream_open_s_key() { + let s = stream(StreamMode::ClientStream); + let state = open_stream(&s.open).unwrap(); + let chunk = arrived(&sign_caller_stream(&data(0, b"up"), &s.open, &s.caller).unwrap()); + let (verified, state) = verify_caller_stream(&chunk, &state, P).unwrap(); + assert_eq!(verified.signer, s.caller.key_id()); + let end = arrived( + &sign_caller_stream( + &StreamFields::End { + seq: 1, + role: StreamRole::Send, + }, + &s.open, + &s.caller, + ) + .unwrap(), + ); + let (_, state) = verify_caller_stream(&end, &state, P).unwrap(); + + // The provider answers a client_stream with a STREAM_REPLY; a caller sends + // none. + let reply = StreamFields::Reply { + seq: 0, + payload: Value::Int(6), + }; + let from_provider = arrived(&sign_provider_stream(&reply, &s.open, &s.provider).unwrap()); + let (verified, _) = verify_provider_stream(&from_provider, &state, P).unwrap(); + assert_eq!(verified.fields, reply); + assert!(matches!( + sign_caller_stream(&reply, &s.open, &s.caller), + Err(FrameError::NotAllowed(_)) + )); +} + +#[test] +fn a_caller_sends_no_data_in_a_server_stream() { + let s = stream(StreamMode::ServerStream); + assert!(matches!( + sign_caller_stream(&data(0, b"x"), &s.open, &s.caller), + Err(FrameError::NotAllowed(_)) + )); + // An end is allowed. + assert!(sign_caller_stream( + &StreamFields::End { + seq: 0, + role: StreamRole::Both + }, + &s.open, + &s.caller + ) + .is_ok()); +} + +#[test] +fn a_stream_frame_for_another_stream_or_signer_is_refused() { + let s = stream(StreamMode::Bidi); + let other = stream(StreamMode::Bidi); + let state: StreamState = open_stream(&s.open).unwrap(); + // Signed by the provider of another stream: unsignable here, and refused + // when it arrives. + assert_eq!( + sign_provider_stream(&data(0, b"x"), &s.open, &other.provider).unwrap_err(), + FrameError::Unsignable + ); + let foreign = + arrived(&sign_provider_stream(&data(0, b"x"), &other.open, &other.provider).unwrap()); + assert_eq!( + verify_provider_stream(&foreign, &state, P).unwrap_err(), + FrameError::RequestMismatch + ); + let raw_value = StreamFields::Data { + seq: 0, + encoding: StreamEncoding::Raw, + body: Value::Int(1), + }; + assert!(matches!( + sign_provider_stream(&raw_value, &s.open, &s.provider), + Err(FrameError::OutOfRange(_)) + )); + let error = StreamFields::Error { + seq: 0, + code: "c".repeat(65), + message: String::new(), + }; + assert!(matches!( + sign_provider_stream(&error, &s.open, &s.provider), + Err(FrameError::TextTooLong(_)) + )); +} + +#[test] +fn a_stream_opens_only_on_a_stream_open() { + let s = stream(StreamMode::Bidi); + let mut call = s.open.clone(); + call.frame_type = macula_rust::frame::RequestType::Call; + call.mode = None; + assert!(matches!(open_stream(&call), Err(FrameError::OutOfRange(_)))); +} From bf49adbd69563c299560c8fb200c3e7f12a765c9 Mon Sep 17 00:00:00 2001 From: beamologist Date: Sat, 26 Sep 2026 08:32:29 +0200 Subject: [PATCH 07/12] record: macula 12's DHT records, storage keys, and provider authorization (D25 org directories and delegations, own namespace) Held to macula's own-namespace fixtures and verdicts, both profiles. Node records, procedure advertisements, station endpoints, content announcements, tombstones and domain envelopes sign and verify in macula's order. Co-Authored-By: Claude Opus 5.5 --- src/frame.rs | 24 +- src/lib.rs | 2 + src/record.rs | 638 ++++++++++++++++++++++++++ src/record/authorization.rs | 220 +++++++++ src/record/content_announcement.rs | 102 ++++ src/record/node_record.rs | 187 ++++++++ src/record/payload.rs | 152 ++++++ src/record/procedure_advertisement.rs | 128 ++++++ src/record/station_endpoint.rs | 88 ++++ src/record/storage_key.rs | 134 ++++++ src/record/tombstone.rs | 147 ++++++ src/uuid_v7.rs | 22 + tests/record.rs | 477 +++++++++++++++++++ 13 files changed, 2299 insertions(+), 22 deletions(-) create mode 100644 src/record.rs create mode 100644 src/record/authorization.rs create mode 100644 src/record/content_announcement.rs create mode 100644 src/record/node_record.rs create mode 100644 src/record/payload.rs create mode 100644 src/record/procedure_advertisement.rs create mode 100644 src/record/station_endpoint.rs create mode 100644 src/record/storage_key.rs create mode 100644 src/record/tombstone.rs create mode 100644 src/uuid_v7.rs create mode 100644 tests/record.rs diff --git a/src/frame.rs b/src/frame.rs index c3136a8..8ba2262 100644 --- a/src/frame.rs +++ b/src/frame.rs @@ -379,8 +379,8 @@ fn base(frame_type: &str) -> Vec<(Value, Value)> { vec![ entry("version", Value::Int(i128::from(PROTOCOL_VERSION))), entry("frame_type", Value::text(frame_type)), - entry("frame_id", Value::Bytes(fresh_frame_id().to_vec())), - entry("sent_at_ms", uint(now_ms())), + entry("frame_id", Value::Bytes(crate::uuid_v7::new().to_vec())), + entry("sent_at_ms", uint(crate::uuid_v7::now_ms())), entry("capabilities", uint(0)), entry("realm", Value::Null), entry("call_id", Value::Null), @@ -397,23 +397,3 @@ fn with_field(mut fields: Vec<(Value, Value)>, key: &str, value: Value) -> Vec<( } fields } - -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) -} - -/// A UUID v7: 48 bits of Unix milliseconds, the version and variant bits, and -/// 74 random bits. -fn fresh_frame_id() -> [u8; 16] { - let mut id = [0u8; 16]; - // A frame id is an identifier, not a secret: an id without randomness is - // still unique by its time, so a failure to draw is not an error here. - let _ = aws_lc_rs::rand::fill(&mut id[6..]); - id[..6].copy_from_slice(&now_ms().to_be_bytes()[2..]); - id[6] = (id[6] & 0x0f) | 0x70; - id[8] = (id[8] & 0x3f) | 0x80; - id -} diff --git a/src/lib.rs b/src/lib.rs index 736e1d6..de7c519 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,5 +13,7 @@ pub mod keystore; pub mod node_key; pub mod petname; pub mod profile; +pub mod record; pub mod signed_object; pub mod transport; +mod uuid_v7; diff --git a/src/record.rs b/src/record.rs new file mode 100644 index 0000000..65b6ce2 --- /dev/null +++ b/src/record.rs @@ -0,0 +1,638 @@ +//! macula 12's DHT records, as macula_record and macula-go sign and verify +//! them. A record is the signed object `{key, tbs, signature}` under +//! MACULA-PQ-RECORD-V1; its tbs holds type, alg, version, created_at, +//! expires_at and payload, and subject only on a domain type (tags 0x20 to +//! 0xFF). [`sign`] refuses a key whose purpose does not fit the type; [`verify`] +//! reads a record's wire form in the design's order and keeps its tbs bytes, +//! so [`encode`] sends them unchanged. +//! +//! A record is named by the key id of its key: the node_id for node records, +//! procedure advertisements, content announcements and station endpoints, and +//! the key id for every other type. A tombstone is named as the type it +//! withdraws. + +mod authorization; +mod content_announcement; +mod node_record; +mod payload; +mod procedure_advertisement; +mod station_endpoint; +mod storage_key; +mod tombstone; + +pub use authorization::{ + in_own_namespace, namespace_node, own_namespace, own_procedure, procedure_org, + read_org_directory, read_procedure_delegation, verify_authorization, OrgDirectory, + ProcedureDelegation, Trust, OWN_NAMESPACE_PREFIX, +}; +pub use content_announcement::{ + new_content_announcement, read_content_announcement, ContentAnnouncement, + ContentAnnouncementOptions, +}; +pub use node_record::{new_node_record, read_node_record, NodeRecord, NodeRecordOptions}; +pub use procedure_advertisement::{ + new_procedure_advertisement, read_procedure_advertisement, Authorization, + ProcedureAdvertisement, ProcedureAdvertisementOptions, +}; +pub use station_endpoint::{ + new_station_endpoint, read_station_endpoint, StationEndpoint, StationEndpointOptions, +}; +pub use storage_key::{ + content_key, org_directory_key, procedure_delegation_key, procedure_key, station_endpoint_key, + storage_key, +}; +pub use tombstone::{new_tombstone, read_tombstone, Reason, Tombstone, TombstoneOptions}; + +use std::fmt; + +use crate::cbor::{self, Value}; +use crate::node_key::{key_id_of, node_id_of, signature_size, KeyError, NodeKey, Purpose}; +use crate::profile::Profile; +use crate::signed_object::{sign_object, verify_object, Object, ObjectError}; + +const LABEL: &str = "MACULA-PQ-RECORD-V1"; + +/// The longest wire form a record may have, 256 KiB. +pub const MAX_RECORD_BYTES: usize = 256 * 1024; + +/// How far a verifier's clock may be from a record's created_at and +/// expires_at, 5 minutes. +pub const CLOCK_TOLERANCE_MS: u64 = 5 * MINUTE_MS; + +/// The longest a realm member endorsement admits its member, 30 days. +pub const MAX_ENDORSEMENT_WINDOW_MS: u64 = 30 * DAY_MS; + +const MAX_PROTOCOL_INT: u64 = 1 << 53; +const MAX_PAYLOAD_NESTING: usize = 63; +const MINUTE_MS: u64 = 60 * 1000; +const HOUR_MS: u64 = 60 * MINUTE_MS; +const DAY_MS: u64 = 24 * HOUR_MS; + +/// The longest a record of a type lives (D28). +const NODE_RECORD_MAX_LIFETIME_MS: u64 = 48 * HOUR_MS; +const CONTENT_ANNOUNCEMENT_MAX_LIFETIME_MS: u64 = 48 * HOUR_MS; +const PROCEDURE_ADVERTISEMENT_MAX_LIFETIME_MS: u64 = 5 * MINUTE_MS; +const STATION_ENDPOINT_TTL_MS: u64 = 5 * MINUTE_MS; +const REALM_AND_ORG_MAX_LIFETIME_MS: u64 = 6 * HOUR_MS; +const DOMAIN_RECORD_MAX_LIFETIME_MS: u64 = 7 * DAY_MS; +const DEFAULT_MAX_LIFETIME_MS: u64 = 30 * DAY_MS; +const DEFAULT_TTL_MS: u64 = 48 * HOUR_MS; + +/// A record's type tag. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct RecordType(pub u8); + +impl RecordType { + pub const NODE_RECORD: RecordType = RecordType(0x01); + pub const REALM_DIRECTORY: RecordType = RecordType(0x03); + pub const REALM_STATIONS: RecordType = RecordType(0x04); + pub const REALM_MEMBER_ENDORSEMENT: RecordType = RecordType(0x05); + pub const PROCEDURE_ADVERTISEMENT: RecordType = RecordType(0x06); + pub const TOMBSTONE: RecordType = RecordType(0x0C); + pub const FOUNDATION_SEED_LIST: RecordType = RecordType(0x0D); + pub const FOUNDATION_PARAMETER: RecordType = RecordType(0x0E); + pub const FOUNDATION_REALM_TRUST_LIST: RecordType = RecordType(0x0F); + pub const FOUNDATION_T3_ATTESTATION: RecordType = RecordType(0x10); + pub const CONTENT_ANNOUNCEMENT: RecordType = RecordType(0x11); + pub const STATION_ENDPOINT: RecordType = RecordType(0x12); + pub const ORG_DIRECTORY: RecordType = RecordType(0x15); + pub const PROCEDURE_DELEGATION: RecordType = RecordType(0x16); + /// Tags from here to 0xFF are domain types, whose owners set their + /// payload rules. + pub const DOMAIN_MIN: RecordType = RecordType(0x20); + + fn is_domain(self) -> bool { + self >= RecordType::DOMAIN_MIN + } +} + +/// The refusals of a record, named as macula_record names them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecordError { + /// A wire form over 256 KiB. + TooLarge, + /// A record without exactly the shape, fields and payload of its type, + /// and what. + Malformed(String), + /// Created more than 5 minutes ahead of the verifier's clock. + NotYetValid, + /// Expired more than 5 minutes before the verifier's clock. + Expired, + /// A payload that names a signer other than the key id of its key. + KeyIdMismatch, + /// A lifetime over its type's maximum. + LifetimeTooLong, + /// Expires no later than it is created. + LifetimeReversed, + /// A key whose purpose does not fit the type it would sign. + KeyPurposeMismatch, + /// A record with no key, tbs or signature to encode. + Unsigned, + /// A domain envelope for a built-in type. + NotADomainType, + /// A domain record's subject that is empty. + InvalidSubject, + /// A signature that does not verify. + SignatureInvalid, + /// A signed object whose alg names another profile's algorithm. + AlgMismatch, + /// A node record coordinate that is not a number within its range. + InvalidCoordinate(String), + /// A station endpoint's QUIC port of 0. + InvalidPort, + /// Not a tag 2 content id of 50 bytes. + NotAContentId, + /// A tombstone built to withdraw a tombstone. + TombstoneOfATombstone, + /// An org procedure's advertisement that carries no authorization. + NoAuthorization, + /// An authorization on a procedure whose namespace takes none. + AuthorizationNotAllowed, + /// An authorization in a form macula 12 does not have, a certificate + /// chain among them. + AuthorizationFormUnsupported, + /// An advertisement that is not in its advertiser's own namespace. + NotOwnNamespace, + /// An authorization checked without a trusted realm key. + NoRealmKey, + /// An org directory that does not verify as one, and why. + OrgDirectoryInvalid(String), + /// An org directory the trusted realm key did not sign, or for another + /// realm. + OrgDirectoryWrongRealm, + /// An org directory for another org. + OrgDirectoryWrongOrg, + /// A procedure delegation that does not verify as one, and why. + DelegationInvalid(String), + /// A delegation the org key did not sign, or for another advertiser. + DelegationMismatch, + /// An advertisement that expires after its org directory or delegation. + AuthorizationOutlived, + /// The key could not sign. + Key(KeyError), +} + +impl fmt::Display for RecordError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RecordError::Malformed(why) => write!(f, "malformed record: {why}"), + RecordError::InvalidCoordinate(what) => { + write!(f, "a coordinate outside its range: {what}") + } + RecordError::OrgDirectoryInvalid(why) => { + write!(f, "the org directory does not verify: {why}") + } + RecordError::DelegationInvalid(why) => { + write!(f, "the procedure delegation does not verify: {why}") + } + RecordError::Key(e) => write!(f, "{e}"), + other => write!(f, "{other:?}"), + } + } +} + +impl std::error::Error for RecordError {} + +fn malformed(why: impl Into) -> RecordError { + RecordError::Malformed(why.into()) +} + +/// What signing or verifying gave a record: the key as carried, its key id, +/// the alg, the tbs bytes, and the signature. +#[derive(Debug, Clone, PartialEq)] +pub struct Signed { + pub key: Vec, + pub key_id: [u8; 32], + pub alg: String, + pub tbs: Vec, + pub signature: Vec, +} + +/// A record: unsigned as a builder returns it, or signed or verified, when +/// `signed` holds what signing or verifying gave. The payload is a map. +/// `subject` names a domain record's subject, `None` for none and on every +/// other type. +#[derive(Debug, Clone, PartialEq)] +pub struct Record { + pub record_type: RecordType, + pub version: [u8; 16], + pub created_at: u64, + pub expires_at: u64, + pub payload: Value, + pub subject: Option>, + pub signed: Option, +} + +/// A record of `record_type`, created now with a UUID v7 version, living +/// `ttl_ms`, or its type's default when `ttl_ms` is 0. +fn unsigned(record_type: RecordType, payload: Value, ttl_ms: u64) -> Record { + let ttl_ms = if ttl_ms == 0 { + default_ttl(record_type) + } else { + ttl_ms + }; + let now = crate::uuid_v7::now_ms(); + Record { + record_type, + version: crate::uuid_v7::new(), + created_at: now, + expires_at: now + ttl_ms, + payload, + subject: None, + signed: None, + } +} + +/// An unsigned record of a domain type, 0x20 to 0xFF, with `subject`, living +/// `ttl_ms`, or 48 hours when 0. An empty subject would name a slot apart +/// from no subject, so it is refused. +pub fn envelope( + record_type: u8, + payload: Value, + subject: Option>, + ttl_ms: u64, +) -> Result { + let t = RecordType(record_type); + if !t.is_domain() { + return Err(RecordError::NotADomainType); + } + if subject.as_ref().is_some_and(|s| s.is_empty()) { + return Err(RecordError::InvalidSubject); + } + let mut r = unsigned(t, payload, ttl_ms); + r.subject = subject; + Ok(r) +} + +/// Signs `r` with `key`, as macula_record's sign/2 does. Refused, in +/// macula's order: a key whose purpose does not fit the type; a lifetime that +/// runs backwards or past its type's maximum; a payload that names a signer +/// other than the key; a tbs or payload a verifier would refuse; and a record +/// over 256 KiB. +pub fn sign(r: &Record, key: &NodeKey) -> Result { + let profile = key.profile(); + if !signer_may_sign(r.record_type.0, &r.payload, key.purpose()) { + return Err(RecordError::KeyPurposeMismatch); + } + lifetime(r)?; + let carried = key.public_key(); + let key_id = key_id_by_kind(r.record_type.0, &r.payload, &carried, profile); + if !named_signer(r.record_type, &r.payload, &key_id) { + return Err(RecordError::KeyIdMismatch); + } + let fields = tbs_fields(r); + let mut with_alg = fields.clone(); + with_alg.push((Value::text("alg"), Value::text(profile.sig_alg()))); + let encoded = cbor::encode(&Value::Map(with_alg)).map_err(|e| malformed(e.to_string()))?; + let tbs = cbor::decode(&encoded) + .map_err(|e| malformed(format!("a tbs the decoding rule refuses: {e}")))?; + match read_tbs(&tbs) { + Some(read) if payload::payload_ok(read.record_type, &read.payload) => {} + _ => return Err(malformed("a record a verifier would refuse")), + } + let unsigned_size = cbor::encode(&Value::Map(fields.clone())) + .map_err(|e| malformed(e.to_string()))? + .len() + + carried.len() + + signature_size(profile); + if unsigned_size > MAX_RECORD_BYTES { + return Err(RecordError::TooLarge); + } + let object = sign_object(LABEL, &fields, key).map_err(|e| match e { + ObjectError::Key(k) => RecordError::Key(k), + other => malformed(other.to_string()), + })?; + let wire = cbor::encode(&object.to_value()).map_err(|e| malformed(e.to_string()))?; + if wire.len() > MAX_RECORD_BYTES { + return Err(RecordError::TooLarge); + } + let mut out = r.clone(); + out.signed = Some(Signed { + key: object.key, + key_id, + alg: profile.sig_alg().to_string(), + tbs: object.tbs, + signature: object.signature, + }); + Ok(out) +} + +/// `r` with a new version, created now, with the same lifetime, signed again +/// with `key`. +pub fn refresh(r: &Record, key: &NodeKey) -> Result { + let now = crate::uuid_v7::now_ms(); + let fresh = Record { + version: crate::uuid_v7::new(), + created_at: now, + expires_at: now + r.expires_at.saturating_sub(r.created_at), + signed: None, + ..r.clone() + }; + sign(&fresh, key) +} + +/// The wire form of a signed or verified record: its `{key, tbs, signature}` +/// map, tbs unchanged. +pub fn encode(r: &Record) -> Result, RecordError> { + let signed = r.signed.as_ref().ok_or(RecordError::Unsigned)?; + let object = Object { + key: signed.key.clone(), + tbs: signed.tbs.clone(), + signature: signed.signature.clone(), + }; + cbor::encode(&object.to_value()).map_err(|e| malformed(e.to_string())) +} + +/// A record [`verify`] returned. Only `verify` makes one. +#[derive(Debug, Clone, PartialEq)] +pub struct Verified(Record); + +impl Verified { + /// The verified record. + pub fn record(&self) -> &Record { + &self.0 + } + + /// The verified record, taken. + pub fn into_record(self) -> Record { + self.0 + } +} + +/// Reads a record's wire form under the verifier's `profile` and clock +/// `now_ms`, as macula_record's verify/3 does, in this order: a wire form +/// over 256 KiB, before anything is decoded; a signed object that carries its +/// key; its signature and alg; a tbs of exactly its fields; created_at no more +/// than 5 minutes ahead and expires_at no more than 5 minutes behind; a +/// lifetime within its type's maximum; its type's payload rules; and a payload +/// that names its signer by the key's id. +pub fn verify(wire: &[u8], profile: Profile, now_ms: i64) -> Result { + if wire.len() > MAX_RECORD_BYTES { + return Err(RecordError::TooLarge); + } + let value = cbor::decode(wire).map_err(|e| malformed(e.to_string()))?; + let object = Object::from_value(&value).map_err(|e| malformed(e.to_string()))?; + let verified = verify_object(LABEL, &value, profile).map_err(|e| match e { + ObjectError::SignatureInvalid => RecordError::SignatureInvalid, + ObjectError::AlgMismatch => RecordError::AlgMismatch, + other => malformed(other.to_string()), + })?; + let mut r = read_tbs(&verified.fields).ok_or_else(|| malformed("a tbs of another shape"))?; + let created = r.created_at as i64; + let expires = r.expires_at as i64; + let tolerance = CLOCK_TOLERANCE_MS as i64; + if created > now_ms + tolerance { + return Err(RecordError::NotYetValid); + } + if expires + tolerance < now_ms { + return Err(RecordError::Expired); + } + lifetime(&r)?; + if !payload::payload_ok(r.record_type, &r.payload) { + return Err(malformed("a payload its type's rules refuse")); + } + let key_id = key_id_by_kind(r.record_type.0, &r.payload, &verified.key, profile); + if !named_signer(r.record_type, &r.payload, &key_id) { + return Err(RecordError::KeyIdMismatch); + } + r.signed = Some(Signed { + key: verified.key, + key_id, + alg: profile.sig_alg().to_string(), + tbs: verified.tbs, + signature: object.signature, + }); + Ok(Verified(r)) +} + +/// Checks a payload before anything is signed: its encoding is at most 256 +/// KiB, and it nests at most 63 levels, which a record's tbs leaves it under +/// the decoding rule's 64. +pub fn payload_bounded(payload: &Value) -> Result<(), RecordError> { + let size = cbor::encode(payload) + .map_err(|e| malformed(e.to_string()))? + .len(); + if size > MAX_RECORD_BYTES { + return Err(RecordError::TooLarge); + } + if nesting(payload, 0) > MAX_PAYLOAD_NESTING { + return Err(malformed("a payload nested past 63 levels")); + } + Ok(()) +} + +fn nesting(v: &Value, depth: usize) -> usize { + let children: Vec<&Value> = match v { + Value::List(items) => items.iter().collect(), + Value::Map(pairs) => pairs.iter().flat_map(|(k, v)| [k, v]).collect(), + _ => return depth, + }; + let mut deepest = depth + 1; + for child in children { + if deepest > MAX_PAYLOAD_NESTING { + break; + } + deepest = deepest.max(nesting(child, depth + 1)); + } + deepest +} + +fn tbs_fields(r: &Record) -> Vec<(Value, Value)> { + let mut fields = vec![ + (Value::text("type"), Value::Int(i128::from(r.record_type.0))), + (Value::text("version"), Value::Bytes(r.version.to_vec())), + ( + Value::text("created_at"), + Value::Int(i128::from(r.created_at)), + ), + ( + Value::text("expires_at"), + Value::Int(i128::from(r.expires_at)), + ), + (Value::text("payload"), r.payload.clone()), + ]; + if let Some(subject) = &r.subject { + fields.push((Value::text("subject"), Value::Bytes(subject.clone()))); + } + fields +} + +/// A verified record's tbs, as macula_record's read_tbs does: exactly type +/// (1 to 255), alg, version (16 bytes), created_at and expires_at (below +/// 2^53), payload (a map), and subject (bytes, not empty) only on a domain +/// type. +fn read_tbs(tbs: &Value) -> Option { + let Value::Map(pairs) = tbs else { + return None; + }; + let field = |name: &str| tbs.get(name); + let record_type = match field("type")? { + Value::Int(n) if (1..=255).contains(n) => RecordType(*n as u8), + _ => return None, + }; + let Value::Text(_) = field("alg")? else { + return None; + }; + let version: [u8; 16] = match field("version")? { + Value::Bytes(b) => b.as_slice().try_into().ok()?, + _ => return None, + }; + let created_at = protocol_uint(field("created_at")?)?; + let expires_at = protocol_uint(field("expires_at")?)?; + let payload = field("payload")?; + if !matches!(payload, Value::Map(_)) { + return None; + } + let mut r = Record { + record_type, + version, + created_at, + expires_at, + payload: payload.clone(), + subject: None, + signed: None, + }; + match (pairs.len(), field("subject")) { + (6, None) => Some(r), + (7, Some(Value::Bytes(subject))) if !subject.is_empty() && record_type.is_domain() => { + r.subject = Some(subject.clone()); + Some(r) + } + _ => None, + } +} + +fn protocol_uint(v: &Value) -> Option { + match v { + Value::Int(n) if *n >= 0 && *n < i128::from(MAX_PROTOCOL_INT) => Some(*n as u64), + _ => None, + } +} + +/// Refuses a record whose lifetime does not run forward, or is longer than +/// its type's maximum. +fn lifetime(r: &Record) -> Result<(), RecordError> { + let lived = r.expires_at as i128 - r.created_at as i128; + if lived <= 0 { + return Err(RecordError::LifetimeReversed); + } + if lived > i128::from(max_lifetime(i128::from(r.record_type.0), &r.payload)) { + return Err(RecordError::LifetimeTooLong); + } + Ok(()) +} + +/// The withdrawn_type a tombstone's payload names, when it is an integer. +fn withdrawn_type(payload: &Value) -> Option> { + match payload.get("withdrawn_type") { + None => None, + Some(Value::Int(n)) => Some(Some(*n)), + Some(_) => Some(None), + } +} + +/// The longest a record of type `t` lives, as macula_record's max_lifetime/2 +/// has it. A tombstone's follows the type it withdraws, plus twice the clock +/// tolerance. +fn max_lifetime(t: i128, payload: &Value) -> u64 { + match t { + 0x01 => NODE_RECORD_MAX_LIFETIME_MS, + 0x11 => CONTENT_ANNOUNCEMENT_MAX_LIFETIME_MS, + 0x06 => PROCEDURE_ADVERTISEMENT_MAX_LIFETIME_MS, + 0x12 => STATION_ENDPOINT_TTL_MS, + 0x04 | 0x15 | 0x16 => REALM_AND_ORG_MAX_LIFETIME_MS, + 0x05 => MAX_ENDORSEMENT_WINDOW_MS, + 0x0C => match withdrawn_type(payload) { + None | Some(Some(0x0C)) => DEFAULT_MAX_LIFETIME_MS, + Some(None) => DEFAULT_MAX_LIFETIME_MS + 2 * CLOCK_TOLERANCE_MS, + Some(Some(w)) => max_lifetime(w, &Value::Map(Vec::new())) + 2 * CLOCK_TOLERANCE_MS, + }, + t if t >= 0x20 => DOMAIN_RECORD_MAX_LIFETIME_MS, + _ => DEFAULT_MAX_LIFETIME_MS, + } +} + +/// The lifetime a builder given no ttl takes: 48 hours, or the type's +/// maximum when shorter. +fn default_ttl(t: RecordType) -> u64 { + DEFAULT_TTL_MS.min(max_lifetime(i128::from(t.0), &Value::Map(Vec::new()))) +} + +/// Whether a key of `purpose` may sign a record of type `t`, as +/// macula_record's signer_purposes/2 has it: an identity key signs node +/// records, procedure advertisements, content announcements, station +/// endpoints and domain records; the realm, org and foundation types are +/// their keys'; a tombstone is signed like the type it withdraws. +fn signer_may_sign(t: u8, payload: &Value, purpose: Purpose) -> bool { + match t { + 0x0C => match withdrawn_type(payload) { + Some(Some(w)) if (0..=255).contains(&w) && w != 0x0C => { + signer_may_sign(w as u8, &Value::Map(Vec::new()), purpose) + } + _ => false, + }, + 0x01 | 0x06 | 0x11 | 0x12 => purpose == Purpose::Identity, + t if t >= 0x20 => purpose == Purpose::Identity, + _ => false, + } +} + +/// Whether a type is signed by some key at all, which a withdrawable type +/// must be. +fn signed_by_some_key(t: i128) -> bool { + matches!(t, 0x01 | 0x03..=0x06 | 0x0D..=0x12 | 0x15 | 0x16) || t >= 0x20 +} + +/// Whether a record of type `t` names its signer by node_id rather than key +/// id. A tombstone is named as the type it withdraws. +fn named_by_node_id(t: u8, payload: &Value) -> bool { + let t = if t == 0x0C { + match withdrawn_type(payload) { + Some(Some(w)) => w, + _ => return false, + } + } else { + i128::from(t) + }; + matches!(t, 0x01 | 0x06 | 0x11 | 0x12) +} + +fn key_id_by_kind(t: u8, payload: &Value, carried: &[u8], profile: Profile) -> [u8; 32] { + if named_by_node_id(t, payload) { + node_id_of(carried, profile) + } else { + key_id_of(carried, profile) + } +} + +/// Whether the payload field that names a type's signer, where the type has +/// one, holds `key_id`. +fn named_signer(t: RecordType, payload: &Value, key_id: &[u8; 32]) -> bool { + let name = match t { + RecordType::NODE_RECORD => "node_id", + RecordType::PROCEDURE_ADVERTISEMENT => "advertiser_node", + RecordType::CONTENT_ANNOUNCEMENT => "announcer_node", + RecordType::PROCEDURE_DELEGATION => "org_key", + _ => return true, + }; + matches!(payload.get(name), Some(Value::Bytes(b)) if b.as_slice() == key_id) +} + +/// A 32-byte id field of `payload`, or zeros. +fn id_field(payload: &Value, name: &str) -> [u8; 32] { + match payload.get(name) { + Some(Value::Bytes(b)) if b.len() == 32 => b.as_slice().try_into().unwrap_or([0; 32]), + _ => [0; 32], + } +} + +fn text_field(payload: &Value, name: &str) -> String { + match payload.get(name) { + Some(Value::Text(t)) => t.clone(), + _ => String::new(), + } +} + +fn entry(name: &str, value: Value) -> (Value, Value) { + (Value::text(name), value) +} diff --git a/src/record/authorization.rs b/src/record/authorization.rs new file mode 100644 index 0000000..4b960c1 --- /dev/null +++ b/src/record/authorization.rs @@ -0,0 +1,220 @@ +//! A procedure advertisement's provider authorization (D25), as +//! macula_record's verify_authorization/3 and own_namespace/1 decide it. A +//! procedure `org/name` is authorized by the realm's org directory, which +//! names the org's key, and the org's procedure delegation to the +//! advertiser, both carried in the advertisement. A procedure in a node's own +//! namespace, `~/name`, is authorized by the advertisement's +//! signature alone, and needs no realm key. A procedure without a namespace +//! carries no authorization. + +use crate::profile::Profile; + +use super::{ + id_field, malformed, read_procedure_advertisement, text_field, verify, Authorization, + ProcedureAdvertisement, Record, RecordError, RecordType, Verified, +}; + +/// Starts the namespace of a node's own procedures, `~/`. +pub const OWN_NAMESPACE_PREFIX: &str = "~"; + +/// What a caller trusts for its realm: the verifier's profile and the realm +/// key as carried, `None` when none is pinned. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Trust { + pub profile: Profile, + pub realm_key: Option>, +} + +/// A procedure's org namespace: the text before the first `/` of its name. +/// A name without a slash, or with `_` before it, has none; a name starting +/// with a slash is malformed. +pub fn procedure_org(procedure: &str) -> Result, RecordError> { + match procedure.split_once('/') { + None => Ok(None), + Some(("_", _)) => Ok(None), + Some(("", _)) => Err(malformed("a procedure name starting with a slash")), + Some((org, _)) => Ok(Some(org)), + } +} + +/// Whether `procedure` names a node's own namespace, `~` before its first +/// slash, spelled well or not. +pub fn in_own_namespace(procedure: &str) -> bool { + matches!(procedure_org(procedure), Ok(Some(org)) if org.starts_with(OWN_NAMESPACE_PREFIX)) +} + +/// `name` in the own namespace of `node`: `~/name`. +pub fn own_procedure(node: &[u8; 32], name: &str) -> String { + let hex: String = node.iter().map(|b| format!("{b:02x}")).collect(); + format!("{OWN_NAMESPACE_PREFIX}{hex}/{name}") +} + +/// The node_id a `~` namespace names: exactly 64 lowercase hex characters, +/// the one spelling of a node_id in a namespace. +pub fn namespace_node(hex_node: &str) -> Result<[u8; 32], RecordError> { + let bad = || malformed("a ~ namespace is 64 lowercase hex characters"); + if hex_node.len() != 64 + || !hex_node + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(bad()); + } + let mut node = [0u8; 32]; + for (i, byte) in node.iter_mut().enumerate() { + *byte = u8::from_str_radix(&hex_node[2 * i..2 * i + 2], 16).map_err(|_| bad())?; + } + Ok(node) +} + +/// Whether a verified procedure advertisement is in its advertiser's own +/// namespace and admissible there: `~/` where node_id is the +/// advertiser_node verifying bound to its signer, with no authorization. +pub fn own_namespace(advertisement: &Verified) -> Result<(), RecordError> { + let r = advertisement.record(); + if r.record_type != RecordType::PROCEDURE_ADVERTISEMENT { + return Err(RecordError::NotOwnNamespace); + } + let read = read_procedure_advertisement(r)?; + match procedure_org(&read.procedure) { + Ok(Some(org)) if org.starts_with(OWN_NAMESPACE_PREFIX) => { + own_node(&org[OWN_NAMESPACE_PREFIX.len()..], &read) + } + _ => Err(RecordError::NotOwnNamespace), + } +} + +fn own_node(hex_node: &str, read: &ProcedureAdvertisement) -> Result<(), RecordError> { + let node = namespace_node(hex_node)?; + if node != read.advertiser_node { + return Err(RecordError::NotOwnNamespace); + } + if read.authorization != Authorization::None { + return Err(RecordError::AuthorizationNotAllowed); + } + Ok(()) +} + +/// A caller's check of a verified procedure advertisement's provider +/// authorization against the realm it trusts, at `now_ms`. An org procedure +/// needs an org directory and a procedure delegation, and the realm key: the +/// directory must verify, carry the realm key and name the advertisement's +/// realm and the procedure's org; the delegation must verify, signed by the +/// org key the directory names, for the advertiser; and the advertisement +/// expires no later than either. +pub fn verify_authorization( + advertisement: &Verified, + trust: &Trust, + now_ms: i64, +) -> Result<(), RecordError> { + let r = advertisement.record(); + if r.record_type != RecordType::PROCEDURE_ADVERTISEMENT { + return Err(malformed("not a procedure advertisement")); + } + let read = read_procedure_advertisement(r)?; + let org = procedure_org(&read.procedure)?; + if let Some(org) = org.filter(|o| o.starts_with(OWN_NAMESPACE_PREFIX)) { + return own_node(&org[OWN_NAMESPACE_PREFIX.len()..], &read); + } + match (org, &read.authorization) { + (None, Authorization::None) => Ok(()), + (None, _) => Err(RecordError::AuthorizationNotAllowed), + (Some(_), Authorization::None) => Err(RecordError::NoAuthorization), + ( + Some(org), + Authorization::Delegation { + org_directory, + procedure_delegation, + }, + ) => delegation_path( + r, + &read, + org, + org_directory, + procedure_delegation, + trust, + now_ms, + ), + (Some(_), Authorization::Unsupported) => Err(RecordError::AuthorizationFormUnsupported), + (Some(_), Authorization::Malformed) => Err(malformed( + "an org directory and a procedure delegation that are not both byte strings", + )), + } +} + +fn delegation_path( + advertisement: &Record, + read: &ProcedureAdvertisement, + org: &str, + directory_wire: &[u8], + delegation_wire: &[u8], + trust: &Trust, + now_ms: i64, +) -> Result<(), RecordError> { + let realm_key = trust.realm_key.as_ref().ok_or(RecordError::NoRealmKey)?; + let directory = verify(directory_wire, trust.profile, now_ms) + .map_err(|e| RecordError::OrgDirectoryInvalid(e.to_string()))? + .into_record(); + let named = read_org_directory(&directory) + .map_err(|e| RecordError::OrgDirectoryInvalid(e.to_string()))?; + let directory_key = directory.signed.as_ref().map(|s| &s.key); + if directory_key != Some(realm_key) || named.realm_id != read.realm_id { + return Err(RecordError::OrgDirectoryWrongRealm); + } + if named.org_name != org { + return Err(RecordError::OrgDirectoryWrongOrg); + } + let delegation = verify(delegation_wire, trust.profile, now_ms) + .map_err(|e| RecordError::DelegationInvalid(e.to_string()))? + .into_record(); + let granted = read_procedure_delegation(&delegation) + .map_err(|e| RecordError::DelegationInvalid(e.to_string()))?; + let delegation_key_id = delegation.signed.as_ref().map(|s| s.key_id); + if delegation_key_id != Some(named.org_key) || granted.advertiser != read.advertiser_node { + return Err(RecordError::DelegationMismatch); + } + if advertisement.expires_at > directory.expires_at.min(delegation.expires_at) { + return Err(RecordError::AuthorizationOutlived); + } + Ok(()) +} + +/// An org directory's payload: a realm's statement that the org `org_name` +/// is held by the key with key id `org_key`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OrgDirectory { + pub realm_id: [u8; 32], + pub org_name: String, + pub org_key: [u8; 32], +} + +/// Reads an org directory's payload. +pub fn read_org_directory(r: &Record) -> Result { + if r.record_type != RecordType::ORG_DIRECTORY { + return Err(malformed("not an org directory")); + } + Ok(OrgDirectory { + realm_id: id_field(&r.payload, "realm_id"), + org_name: text_field(&r.payload, "org_name"), + org_key: id_field(&r.payload, "org_key"), + }) +} + +/// A procedure delegation's payload: an org's grant, signed by its org key, +/// that the node `advertiser` may serve procedures under the org. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProcedureDelegation { + pub org_key: [u8; 32], + pub advertiser: [u8; 32], +} + +/// Reads a procedure delegation's payload. +pub fn read_procedure_delegation(r: &Record) -> Result { + if r.record_type != RecordType::PROCEDURE_DELEGATION { + return Err(malformed("not a procedure delegation")); + } + Ok(ProcedureDelegation { + org_key: id_field(&r.payload, "org_key"), + advertiser: id_field(&r.payload, "advertiser"), + }) +} diff --git a/src/record/content_announcement.rs b/src/record/content_announcement.rs new file mode 100644 index 0000000..c6735ef --- /dev/null +++ b/src/record/content_announcement.rs @@ -0,0 +1,102 @@ +//! Content announcements (macula 12.6.0, D27): a node's statement that it +//! shares the content with a tag 2 content id, served on its content +//! procedure in a realm and reachable through a station. + +use crate::cbor::Value; + +use super::{ + entry, id_field, malformed, payload::is_content_id, text_field, unsigned, Record, RecordError, + RecordType, +}; + +/// Where the content is served, then its name, size and chunk count, each +/// left out when empty or `None`; `ttl_ms`, 0 for the default, 48 hours. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ContentAnnouncementOptions { + pub realm_id: [u8; 32], + pub serving_station: [u8; 32], + pub procedure: String, + pub name: String, + pub size: Option, + pub chunk_count: Option, + pub ttl_ms: u64, +} + +/// An unsigned announcement by `announcer_node`, which signs it. A content id +/// of another size or tag, or an empty procedure, is refused. +pub fn new_content_announcement( + announcer_node: &[u8; 32], + mcid: &[u8], + opts: &ContentAnnouncementOptions, +) -> Result { + if !is_content_id(Some(&Value::Bytes(mcid.to_vec()))) { + return Err(RecordError::NotAContentId); + } + if opts.procedure.is_empty() { + return Err(malformed( + "a content announcement names its content procedure", + )); + } + let mut entries = vec![ + entry("announcer_node", Value::Bytes(announcer_node.to_vec())), + entry("mcid", Value::Bytes(mcid.to_vec())), + entry("realm_id", Value::Bytes(opts.realm_id.to_vec())), + entry( + "serving_station", + Value::Bytes(opts.serving_station.to_vec()), + ), + entry("procedure", Value::text(opts.procedure.clone())), + ]; + if !opts.name.is_empty() { + entries.push(entry("name", Value::text(opts.name.clone()))); + } + if let Some(size) = opts.size { + entries.push(entry("size", Value::Int(i128::from(size)))); + } + if let Some(chunks) = opts.chunk_count { + entries.push(entry("chunk_count", Value::Int(i128::from(chunks)))); + } + Ok(unsigned( + RecordType::CONTENT_ANNOUNCEMENT, + Value::Map(entries), + opts.ttl_ms, + )) +} + +/// A content announcement's payload. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ContentAnnouncement { + pub announcer_node: [u8; 32], + pub mcid: Vec, + pub realm_id: [u8; 32], + pub serving_station: [u8; 32], + pub procedure: String, + pub name: String, + pub size: Option, + pub chunk_count: Option, +} + +/// Reads a content announcement's payload. +pub fn read_content_announcement(r: &Record) -> Result { + if r.record_type != RecordType::CONTENT_ANNOUNCEMENT { + return Err(malformed("not a content announcement")); + } + let p = &r.payload; + let optional = |name: &str| match p.get(name) { + Some(Value::Int(n)) if *n >= 0 => u64::try_from(*n).ok(), + _ => None, + }; + Ok(ContentAnnouncement { + announcer_node: id_field(p, "announcer_node"), + mcid: match p.get("mcid") { + Some(Value::Bytes(b)) => b.clone(), + _ => Vec::new(), + }, + realm_id: id_field(p, "realm_id"), + serving_station: id_field(p, "serving_station"), + procedure: text_field(p, "procedure"), + name: text_field(p, "name"), + size: optional("size"), + chunk_count: optional("chunk_count"), + }) +} diff --git a/src/record/node_record.rs b/src/record/node_record.rs new file mode 100644 index 0000000..e3c6d47 --- /dev/null +++ b/src/record/node_record.rs @@ -0,0 +1,187 @@ +//! Node records: a node's statement of the realms it serves, its +//! capabilities and where it is, signed by the node and stored under its +//! node_id. Coordinates travel as text with at most 6 decimals and no +//! trailing zeros, stable across stacks where float encodings are not. + +use crate::cbor::Value; + +use super::{entry, id_field, malformed, text_field, unsigned, Record, RecordError, RecordType}; + +const MAX_GEO_TEXT_BYTES: usize = 32; +const LAT_BOUND: f64 = 90.0; +const LNG_BOUND: f64 = 180.0; + +/// A node record's optional fields: `station_id` is `None` for the node +/// itself; empty text and `None` coordinates are left out; `kind` is +/// "station" or "daemon"; `peers` are kept sorted and once each; `ttl_ms` is +/// 0 for the default, 48 hours. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct NodeRecordOptions { + pub station_id: Option<[u8; 32]>, + pub caps_hint: String, + pub display_name: String, + pub hostname: String, + pub endpoint: String, + pub city: String, + pub country: String, + pub lat: Option, + pub lng: Option, + pub kind: String, + pub peers: Vec<[u8; 32]>, + pub ttl_ms: u64, +} + +/// An unsigned node record about `node_id`, which signs it. A coordinate out +/// of its range, or NaN, is refused. +pub fn new_node_record( + node_id: &[u8; 32], + realms: &[[u8; 32]], + capabilities: u64, + opts: &NodeRecordOptions, +) -> Result { + let mut entries = vec![ + entry("node_id", Value::Bytes(node_id.to_vec())), + entry( + "station_id", + Value::Bytes(opts.station_id.unwrap_or(*node_id).to_vec()), + ), + entry("realms", id_list(realms)), + entry("capabilities", Value::Int(i128::from(capabilities))), + ]; + for (name, value) in [ + ("caps_hint", &opts.caps_hint), + ("display_name", &opts.display_name), + ("hostname", &opts.hostname), + ("endpoint", &opts.endpoint), + ("city", &opts.city), + ("country", &opts.country), + ("kind", &opts.kind), + ] { + if !value.is_empty() { + entries.push(entry(name, Value::text(value.clone()))); + } + } + for (name, value, bound) in [("lat", opts.lat, LAT_BOUND), ("lng", opts.lng, LNG_BOUND)] { + let Some(v) = value else { continue }; + if v.is_nan() || v.abs() > bound { + return Err(RecordError::InvalidCoordinate(format!("{name} {v}"))); + } + entries.push(entry(name, Value::text(geo_text(v)))); + } + let mut peers = opts.peers.clone(); + peers.sort_unstable(); + peers.dedup(); + if !peers.is_empty() { + entries.push(entry("peers", id_list(&peers))); + } + Ok(unsigned( + RecordType::NODE_RECORD, + Value::Map(entries), + opts.ttl_ms, + )) +} + +/// A node record's payload, as macula_record reads it. A field left out, or +/// of another kind, is zero, empty or `None`. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct NodeRecord { + pub node_id: [u8; 32], + pub station_id: [u8; 32], + pub realms: Vec<[u8; 32]>, + pub capabilities: u64, + pub kind: String, + pub hostname: String, + pub endpoint: String, + pub city: String, + pub country: String, + pub lat: Option, + pub lng: Option, + pub display_name: String, + pub caps_hint: String, + pub peers: Vec<[u8; 32]>, + pub version: String, +} + +/// Reads a node record's payload. +pub fn read_node_record(r: &Record) -> Result { + if r.record_type != RecordType::NODE_RECORD { + return Err(malformed("not a node record")); + } + let p = &r.payload; + Ok(NodeRecord { + node_id: id_field(p, "node_id"), + station_id: id_field(p, "station_id"), + realms: read_ids(p.get("realms")), + capabilities: match p.get("capabilities") { + Some(Value::Int(n)) if *n >= 0 => u64::try_from(*n).unwrap_or(0), + _ => 0, + }, + kind: text_field(p, "kind"), + hostname: text_field(p, "hostname"), + endpoint: text_field(p, "endpoint"), + city: text_field(p, "city"), + country: text_field(p, "country"), + lat: parse_geo(&text_field(p, "lat"), LAT_BOUND), + lng: parse_geo(&text_field(p, "lng"), LNG_BOUND), + display_name: text_field(p, "display_name"), + caps_hint: text_field(p, "caps_hint"), + peers: read_ids(p.get("peers")), + version: text_field(p, "version"), + }) +} + +/// A coordinate as macula renders one: 6 decimals, trailing zeros cut, +/// keeping one digit after the point. +fn geo_text(v: f64) -> String { + let s = format!("{v:.6}"); + let s = s.trim_end_matches('0'); + if s.ends_with('.') { + format!("{s}0") + } else { + s.to_string() + } +} + +/// A coordinate's text as macula_record's parse_geo/2 reads it: at most 32 +/// bytes, an optional leading minus, digits, then optionally a dot and +/// digits, within `bound` of zero. +fn parse_geo(s: &str, bound: f64) -> Option { + if s.len() > MAX_GEO_TEXT_BYTES { + return None; + } + let unsigned = s.strip_prefix('-').unwrap_or(s); + let (whole, fraction) = match unsigned.split_once('.') { + Some((w, f)) => (w, Some(f)), + None => (unsigned, None), + }; + let digits = |d: &str| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()); + if !digits(whole) || fraction.is_some_and(|f| !digits(f)) { + return None; + } + let v: f64 = s.parse().ok()?; + if v.abs() > bound { + return None; + } + Some(if v == 0.0 && fraction.is_none() { + 0.0 + } else { + v + }) +} + +fn id_list(ids: &[[u8; 32]]) -> Value { + Value::List(ids.iter().map(|id| Value::Bytes(id.to_vec())).collect()) +} + +fn read_ids(v: Option<&Value>) -> Vec<[u8; 32]> { + match v { + Some(Value::List(items)) => items + .iter() + .filter_map(|item| match item { + Value::Bytes(b) => b.as_slice().try_into().ok(), + _ => None, + }) + .collect(), + _ => Vec::new(), + } +} diff --git a/src/record/payload.rs b/src/record/payload.rs new file mode 100644 index 0000000..d56ac6c --- /dev/null +++ b/src/record/payload.rs @@ -0,0 +1,152 @@ +//! Whether a record's payload holds what its type's rules require, as +//! macula_record's payload_ok/2: every field a storage key or a signer check +//! reads is present, of its kind, and the payloads the design pins hold +//! exactly their keys. A domain type's owner sets its rules. + +use crate::cbor::Value; + +use super::{signed_by_some_key, RecordType}; + +/// A tombstone's own payload fields; the rest are the withdrawn record's slot +/// fields. +const TOMBSTONE_FIELDS: &[&str] = &["withdrawn_type", "withdrawn_version", "reason", "detail"]; + +/// The reasons a tombstone may give. +const TOMBSTONE_REASONS: &[&str] = &["shutdown", "moved", "revoked"]; + +pub(super) fn payload_ok(t: RecordType, payload: &Value) -> bool { + let Value::Map(pairs) = payload else { + return false; + }; + let field = |name: &str| payload.get(name); + match t { + RecordType::NODE_RECORD => is_id(field("node_id")), + RecordType::REALM_DIRECTORY | RecordType::REALM_STATIONS => is_id(field("realm_id")), + RecordType::REALM_MEMBER_ENDORSEMENT => { + is_id(field("realm_id")) && is_id(field("member_node")) + } + RecordType::PROCEDURE_ADVERTISEMENT => advertisement_ok(payload, pairs.len()), + RecordType::TOMBSTONE => tombstone_ok(payload, pairs), + RecordType::FOUNDATION_SEED_LIST + | RecordType::FOUNDATION_REALM_TRUST_LIST + | RecordType::STATION_ENDPOINT => true, + RecordType::FOUNDATION_PARAMETER => is_text(field("param_name")), + RecordType::FOUNDATION_T3_ATTESTATION => is_id(field("station_id")), + RecordType::CONTENT_ANNOUNCEMENT => { + is_id(field("announcer_node")) + && is_content_id(field("mcid")) + && is_id(field("realm_id")) + && is_id(field("serving_station")) + && matches!(field("procedure"), Some(Value::Text(p)) if !p.is_empty()) + } + RecordType::ORG_DIRECTORY => { + is_id(field("realm_id")) && is_text(field("org_name")) && is_id(field("org_key")) + } + RecordType::PROCEDURE_DELEGATION => is_id(field("org_key")) && is_id(field("advertiser")), + t => t >= RecordType::DOMAIN_MIN, + } +} + +/// Exactly realm_id, procedure, advertiser_node and serving_station, with an +/// authorization map when it carries one. +fn advertisement_ok(payload: &Value, size: usize) -> bool { + if !is_id(payload.get("realm_id")) + || !is_text(payload.get("procedure")) + || !is_id(payload.get("advertiser_node")) + || !is_id(payload.get("serving_station")) + { + return false; + } + match payload.get("authorization") { + None => size == 4, + Some(Value::Map(_)) => size == 5, + Some(_) => false, + } +} + +fn tombstone_ok(payload: &Value, pairs: &[(Value, Value)]) -> bool { + let (Some(Value::Int(withdrawn)), Some(Value::Bytes(version)), Some(Value::Text(reason))) = ( + payload.get("withdrawn_type"), + payload.get("withdrawn_version"), + payload.get("reason"), + ) else { + return false; + }; + if version.len() != 16 { + return false; + } + let slot: Vec<&(Value, Value)> = pairs + .iter() + .filter(|(k, _)| !matches!(k, Value::Text(n) if TOMBSTONE_FIELDS.contains(&n.as_str()))) + .collect(); + TOMBSTONE_REASONS.contains(&reason.as_str()) + && withdrawable(*withdrawn) + && payload + .get("detail") + .is_none_or(|d| matches!(d, Value::Text(_))) + && slot_ok(*withdrawn, &slot) +} + +/// Whether a record of type `t` can be withdrawn: a domain type, or a +/// built-in type other than a tombstone that some key signs. +fn withdrawable(t: i128) -> bool { + match t { + 0x20..=0xFF => true, + 1..=0x1F => t != 0x0C && signed_by_some_key(t), + _ => false, + } +} + +/// Whether a tombstone's slot fields are exactly the withdrawn type's, each +/// of its kind; for a domain type, none or a subject that is not empty. +fn slot_ok(t: i128, slot: &[&(Value, Value)]) -> bool { + if t >= 0x20 { + return match slot { + [] => true, + [(Value::Text(name), Value::Bytes(subject))] => { + name == "subject" && !subject.is_empty() + } + _ => false, + }; + } + let names = slot_field_names(t); + slot.len() == names.len() + && slot.iter().all(|(k, v)| matches!(k, Value::Text(n) if names.contains(&n.as_str()) && slot_value_ok(n, v))) +} + +/// The payload fields a type's storage key derives from, besides its +/// signer's key id. +pub(super) fn slot_field_names(t: i128) -> &'static [&'static str] { + match t { + 0x03 | 0x04 => &["realm_id"], + 0x05 => &["realm_id", "member_node"], + 0x06 => &["realm_id", "procedure"], + 0x0E => &["param_name"], + 0x10 => &["station_id"], + 0x11 => &["mcid"], + 0x15 => &["realm_id", "org_name"], + 0x16 => &["advertiser"], + _ => &[], + } +} + +fn slot_value_ok(name: &str, v: &Value) -> bool { + match name { + "procedure" | "param_name" | "org_name" => matches!(v, Value::Text(_)), + "mcid" => is_content_id(Some(v)), + _ => is_id(Some(v)), + } +} + +pub(super) fn is_id(v: Option<&Value>) -> bool { + matches!(v, Some(Value::Bytes(b)) if b.len() == 32) +} + +fn is_text(v: Option<&Value>) -> bool { + matches!(v, Some(Value::Text(_))) +} + +/// An MCID: 50 bytes, tag 2 for SHA-384, a codec byte and the hash (D24). +pub(super) fn is_content_id(v: Option<&Value>) -> bool { + matches!(v, Some(Value::Bytes(b)) if b.len() == 50 && b[0] == 2) +} diff --git a/src/record/procedure_advertisement.rs b/src/record/procedure_advertisement.rs new file mode 100644 index 0000000..44eb4e8 --- /dev/null +++ b/src/record/procedure_advertisement.rs @@ -0,0 +1,128 @@ +//! Procedure advertisements: a node's statement that it serves a procedure in +//! a realm, through a station, signed by the node. A procedure with an org +//! namespace carries its provider authorization inside the payload: the +//! realm's org directory and the org's procedure delegation, as their wire +//! forms (see `authorization`). + +use crate::cbor::Value; + +use super::{entry, id_field, malformed, text_field, unsigned, Record, RecordError, RecordType}; + +/// A procedure advertisement's provider authorization, as macula_record's +/// read_authorization/1 reads it. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum Authorization { + /// The advertisement carries none. + #[default] + None, + /// The realm's org directory and the org's procedure delegation, the one + /// form macula 12 has, as the records' wire forms. + Delegation { + org_directory: Vec, + procedure_delegation: Vec, + }, + /// A map of any other fields, a certificate chain among them. + Unsupported, + /// Not a map, or an org directory and a delegation that are not both + /// byte strings. + Malformed, +} + +/// A procedure advertisement's optional fields: its authorization, and +/// `ttl_ms`, 0 for the default and maximum, 5 minutes. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProcedureAdvertisementOptions { + pub authorization: Authorization, + pub ttl_ms: u64, +} + +/// An unsigned advertisement, by `advertiser_node`, which signs it, of +/// `procedure` in `realm_id`, served through `serving_station`. It builds no +/// authorization but an org directory and a procedure delegation. +pub fn new_procedure_advertisement( + advertiser_node: &[u8; 32], + realm_id: &[u8; 32], + procedure: &str, + serving_station: &[u8; 32], + opts: &ProcedureAdvertisementOptions, +) -> Result { + let mut entries = vec![ + entry("realm_id", Value::Bytes(realm_id.to_vec())), + entry("procedure", Value::text(procedure)), + entry("advertiser_node", Value::Bytes(advertiser_node.to_vec())), + entry("serving_station", Value::Bytes(serving_station.to_vec())), + ]; + match &opts.authorization { + Authorization::None => {} + Authorization::Delegation { + org_directory, + procedure_delegation, + } => entries.push(entry( + "authorization", + Value::Map(vec![ + entry("org_directory", Value::Bytes(org_directory.clone())), + entry( + "procedure_delegation", + Value::Bytes(procedure_delegation.clone()), + ), + ]), + )), + Authorization::Unsupported => return Err(RecordError::AuthorizationFormUnsupported), + Authorization::Malformed => return Err(malformed("an authorization in no form")), + } + Ok(unsigned( + RecordType::PROCEDURE_ADVERTISEMENT, + Value::Map(entries), + opts.ttl_ms, + )) +} + +/// A procedure advertisement's payload. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProcedureAdvertisement { + pub realm_id: [u8; 32], + pub procedure: String, + pub advertiser_node: [u8; 32], + pub serving_station: [u8; 32], + pub authorization: Authorization, +} + +/// Reads a procedure advertisement's payload. +pub fn read_procedure_advertisement(r: &Record) -> Result { + if r.record_type != RecordType::PROCEDURE_ADVERTISEMENT { + return Err(malformed("not a procedure advertisement")); + } + let p = &r.payload; + Ok(ProcedureAdvertisement { + realm_id: id_field(p, "realm_id"), + procedure: text_field(p, "procedure"), + advertiser_node: id_field(p, "advertiser_node"), + serving_station: id_field(p, "serving_station"), + authorization: read_authorization(p), + }) +} + +fn read_authorization(payload: &Value) -> Authorization { + let Some(value) = payload.get("authorization") else { + return Authorization::None; + }; + let Value::Map(pairs) = value else { + return Authorization::Malformed; + }; + let (Some(directory), Some(delegation)) = ( + value.get("org_directory"), + value.get("procedure_delegation"), + ) else { + return Authorization::Unsupported; + }; + if pairs.len() != 2 { + return Authorization::Unsupported; + } + match (directory, delegation) { + (Value::Bytes(d), Value::Bytes(g)) => Authorization::Delegation { + org_directory: d.clone(), + procedure_delegation: g.clone(), + }, + _ => Authorization::Malformed, + } +} diff --git a/src/record/station_endpoint.rs b/src/record/station_endpoint.rs new file mode 100644 index 0000000..ae371c3 --- /dev/null +++ b/src/record/station_endpoint.rs @@ -0,0 +1,88 @@ +//! Station endpoints: where a station is dialled, signed by the station and +//! stored under its node_id's station endpoint key. + +use crate::cbor::Value; + +use super::{entry, malformed, unsigned, Record, RecordError, RecordType}; + +/// A station endpoint's optional fields: the hosts it is dialled at, left +/// out when none; its ALPN, left out when empty; `ttl_ms`, 0 for the default +/// and maximum, 5 minutes. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StationEndpointOptions { + pub host_advertised: Vec, + pub alpn: String, + pub ttl_ms: u64, +} + +/// An unsigned record of a station's dialable endpoint. A QUIC port of 0 is +/// refused. +pub fn new_station_endpoint( + quic_port: u16, + opts: &StationEndpointOptions, +) -> Result { + if quic_port == 0 { + return Err(RecordError::InvalidPort); + } + let mut entries = vec![entry("quic_port", Value::Int(i128::from(quic_port)))]; + if !opts.host_advertised.is_empty() { + entries.push(entry( + "host_advertised", + Value::List( + opts.host_advertised + .iter() + .map(|h| Value::Bytes(h.as_bytes().to_vec())) + .collect(), + ), + )); + } + if !opts.alpn.is_empty() { + entries.push(entry("alpn", Value::text(opts.alpn.clone()))); + } + Ok(unsigned( + RecordType::STATION_ENDPOINT, + Value::Map(entries), + opts.ttl_ms, + )) +} + +/// A station endpoint's payload: its QUIC port, 0 when it carries none from +/// 1 to 65535, and the hosts it is dialled at. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StationEndpoint { + pub quic_port: u16, + pub host_advertised: Vec, +} + +/// Reads a station endpoint's payload. +pub fn read_station_endpoint(r: &Record) -> Result { + if r.record_type != RecordType::STATION_ENDPOINT { + return Err(malformed("not a station endpoint")); + } + let quic_port = match r.payload.get("quic_port") { + Some(Value::Int(n)) if (1..=65535).contains(n) => *n as u16, + _ => 0, + }; + Ok(StationEndpoint { + quic_port, + host_advertised: host_list(r.payload.get("host_advertised")), + }) +} + +/// host_advertised as macula_record's host_list/1 reads it: a list of hosts +/// or a single host, each as bytes or text. +fn host_list(v: Option<&Value>) -> Vec { + let items: Vec<&Value> = match v { + None => return Vec::new(), + Some(Value::List(items)) => items.iter().collect(), + Some(single) => vec![single], + }; + items + .into_iter() + .filter_map(|item| match item { + Value::Bytes(b) => Some(String::from_utf8_lossy(b).into_owned()), + Value::Text(t) => Some(t.clone()), + _ => None, + }) + .collect() +} diff --git a/src/record/storage_key.rs b/src/record/storage_key.rs new file mode 100644 index 0000000..cfc5c17 --- /dev/null +++ b/src/record/storage_key.rs @@ -0,0 +1,134 @@ +//! A record's 32-byte DHT storage key, as macula_record's storage_key/1 +//! derives it. A node record is stored under its node_id, and every other +//! record under SHA-256 over MACULA-PQ-STORAGE-KEY-V1, a zero byte, its type +//! and the fields of its slot, a 32-byte id as it is and any other field +//! length-prefixed. A tombstone takes the key of the record it withdraws. + +use sha2::{Digest, Sha256}; + +use crate::cbor::Value; + +use super::{malformed, payload::is_content_id, Record, RecordError, RecordType}; + +const STORAGE_KEY_LABEL: &[u8] = b"MACULA-PQ-STORAGE-KEY-V1"; + +/// The storage key of `r`. A record stored under its signer must be signed or +/// verified. +pub fn storage_key(r: &Record) -> Result<[u8; 32], RecordError> { + if r.record_type != RecordType::TOMBSTONE { + return slot_key( + i128::from(r.record_type.0), + &r.payload, + r.subject.as_deref(), + r, + ); + } + let Some(Value::Int(withdrawn)) = r.payload.get("withdrawn_type") else { + return Err(malformed("a tombstone without an integer withdrawn_type")); + }; + let subject = match r.payload.get("subject") { + None => None, + Some(Value::Bytes(s)) => Some(s.as_slice()), + Some(_) => return Err(malformed("a tombstone's subject that is not bytes")), + }; + slot_key(*withdrawn, &r.payload, subject, r) +} + +/// The storage key of a procedure's advertisements. +pub fn procedure_key(realm_id: &[u8; 32], procedure: &str) -> [u8; 32] { + derived(0x06, &[realm_id, &length_prefixed(procedure.as_bytes())]) +} + +/// The storage key every announcement of a content id shares. +pub fn content_key(mcid: &[u8]) -> Result<[u8; 32], RecordError> { + if !is_content_id(Some(&Value::Bytes(mcid.to_vec()))) { + return Err(RecordError::NotAContentId); + } + Ok(derived(0x11, &[&length_prefixed(mcid)])) +} + +/// The storage key of a station's endpoint record. +pub fn station_endpoint_key(node_id: &[u8; 32]) -> [u8; 32] { + derived(0x12, &[node_id]) +} + +/// The storage key of an org directory record. +pub fn org_directory_key(realm_id: &[u8; 32], org_name: &str) -> [u8; 32] { + derived(0x15, &[realm_id, &length_prefixed(org_name.as_bytes())]) +} + +/// The storage key of a procedure delegation. +pub fn procedure_delegation_key(org_key_id: &[u8; 32], advertiser: &[u8; 32]) -> [u8; 32] { + derived(0x16, &[org_key_id, advertiser]) +} + +fn slot_key( + t: i128, + payload: &Value, + subject: Option<&[u8]>, + r: &Record, +) -> Result<[u8; 32], RecordError> { + let id = |name: &str| -> Result, RecordError> { + match payload.get(name) { + Some(Value::Bytes(b)) if b.len() == 32 => Ok(b.clone()), + _ => Err(malformed(format!("a storage key needs a 32-byte {name}"))), + } + }; + let text = |name: &str| -> Result, RecordError> { + match payload.get(name) { + Some(Value::Text(t)) => Ok(length_prefixed(t.as_bytes())), + _ => Err(malformed(format!("a storage key needs {name} as text"))), + } + }; + let signer = || -> Result, RecordError> { + r.signed + .as_ref() + .map(|s| s.key_id.to_vec()) + .ok_or(RecordError::Unsigned) + }; + let key = match t { + 0x01 => { + let signer = signer()?; + let mut key = [0u8; 32]; + key.copy_from_slice(&signer); + key + } + 0x03 | 0x04 => derived(t as u8, &[&id("realm_id")?]), + 0x05 => derived(t as u8, &[&id("realm_id")?, &id("member_node")?]), + 0x06 => derived(t as u8, &[&id("realm_id")?, &text("procedure")?]), + 0x0D | 0x0F => derived(t as u8, &[&signer()?]), + 0x0E => derived(t as u8, &[&signer()?, &text("param_name")?]), + 0x10 => derived(t as u8, &[&id("station_id")?]), + 0x11 => match payload.get("mcid") { + Some(Value::Bytes(m)) if is_content_id(Some(&Value::Bytes(m.clone()))) => { + derived(0x11, &[&length_prefixed(m)]) + } + _ => return Err(RecordError::NotAContentId), + }, + 0x12 => derived(t as u8, &[&signer()?]), + 0x15 => derived(t as u8, &[&id("realm_id")?, &text("org_name")?]), + 0x16 => derived(t as u8, &[&signer()?, &id("advertiser")?]), + 0x20..=0xFF => match subject { + Some(s) => derived(t as u8, &[&signer()?, &length_prefixed(s)]), + None => derived(t as u8, &[&signer()?]), + }, + _ => return Err(malformed(format!("no storage key for type {t:#04x}"))), + }; + Ok(key) +} + +fn derived(t: u8, fields: &[&[u8]]) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(STORAGE_KEY_LABEL); + h.update([0, t]); + for field in fields { + h.update(field); + } + h.finalize().into() +} + +fn length_prefixed(b: &[u8]) -> Vec { + let mut out = (b.len() as u32).to_be_bytes().to_vec(); + out.extend_from_slice(b); + out +} diff --git a/src/record/tombstone.rs b/src/record/tombstone.rs new file mode 100644 index 0000000..8aa73fd --- /dev/null +++ b/src/record/tombstone.rs @@ -0,0 +1,147 @@ +//! Tombstones: a signer's withdrawal of one of its records, stored in the +//! withdrawn record's slot and signed with the key that signed it. + +use crate::cbor::Value; + +use super::payload::slot_field_names; +use super::{ + entry, id_field, malformed, text_field, unsigned, Record, RecordError, RecordType, + CLOCK_TOLERANCE_MS, +}; + +/// Why a tombstone withdraws a record. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reason { + Shutdown, + Moved, + Revoked, +} + +impl Reason { + fn name(self) -> &'static str { + match self { + Reason::Shutdown => "shutdown", + Reason::Moved => "moved", + Reason::Revoked => "revoked", + } + } +} + +/// A tombstone's optional fields: `detail`, left out when empty, and +/// `ttl_ms`, 0 for the clock tolerance, 5 minutes. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TombstoneOptions { + pub detail: String, + pub ttl_ms: u64, +} + +/// An unsigned tombstone that withdraws `withdrawn`: it names the record's +/// type, version and slot fields, takes its slot, and lives until the record +/// has expired plus the clock tolerance, or `ttl_ms` past its own creation +/// when later, so no replica serves the record again after it lapses. +pub fn new_tombstone( + withdrawn: &Record, + reason: Reason, + opts: &TombstoneOptions, +) -> Result { + if withdrawn.record_type == RecordType::TOMBSTONE { + return Err(RecordError::TombstoneOfATombstone); + } + let mut entries = vec![ + entry( + "withdrawn_type", + Value::Int(i128::from(withdrawn.record_type.0)), + ), + entry( + "withdrawn_version", + Value::Bytes(withdrawn.version.to_vec()), + ), + entry("reason", Value::text(reason.name())), + ]; + entries.extend(slot_fields(withdrawn)?); + if !opts.detail.is_empty() { + entries.push(entry("detail", Value::text(opts.detail.clone()))); + } + let ttl_ms = if opts.ttl_ms == 0 { + CLOCK_TOLERANCE_MS + } else { + opts.ttl_ms + }; + let mut r = unsigned(RecordType::TOMBSTONE, Value::Map(entries), ttl_ms); + r.expires_at = (r.created_at + ttl_ms).max(withdrawn.expires_at + CLOCK_TOLERANCE_MS); + Ok(r) +} + +fn slot_fields(withdrawn: &Record) -> Result, RecordError> { + if withdrawn.record_type >= RecordType::DOMAIN_MIN { + return Ok(withdrawn + .subject + .as_ref() + .map(|s| vec![entry("subject", Value::Bytes(s.clone()))]) + .unwrap_or_default()); + } + slot_field_names(i128::from(withdrawn.record_type.0)) + .iter() + .map(|name| { + withdrawn + .payload + .get(name) + .map(|v| entry(name, v.clone())) + .ok_or_else(|| malformed(format!("the withdrawn record has no {name}"))) + }) + .collect() +} + +/// A tombstone's payload: what it withdraws, why, and the withdrawn record's +/// slot fields. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Tombstone { + pub withdrawn_type: RecordType, + pub withdrawn_version: [u8; 16], + pub reason: String, + pub detail: String, + pub realm_id: [u8; 32], + pub member_node: [u8; 32], + pub procedure: String, + pub param_name: String, + pub station_id: [u8; 32], + pub mcid: Vec, + pub org_name: String, + pub advertiser: [u8; 32], + pub subject: Vec, +} + +/// Reads a tombstone's payload. +pub fn read_tombstone(r: &Record) -> Result { + if r.record_type != RecordType::TOMBSTONE { + return Err(malformed("not a tombstone")); + } + let p = &r.payload; + let withdrawn = match p.get("withdrawn_type") { + Some(Value::Int(n)) if (1..=255).contains(n) => RecordType(*n as u8), + _ => { + return Err(malformed( + "a withdrawn_type that is not an integer from 1 to 255", + )) + } + }; + let bytes = |name: &str| match p.get(name) { + Some(Value::Bytes(b)) => b.clone(), + _ => Vec::new(), + }; + Ok(Tombstone { + withdrawn_type: withdrawn, + withdrawn_version: bytes("withdrawn_version").try_into().unwrap_or([0; 16]), + reason: text_field(p, "reason"), + detail: text_field(p, "detail"), + realm_id: id_field(p, "realm_id"), + member_node: id_field(p, "member_node"), + procedure: text_field(p, "procedure"), + param_name: text_field(p, "param_name"), + station_id: id_field(p, "station_id"), + mcid: bytes("mcid"), + org_name: text_field(p, "org_name"), + advertiser: id_field(p, "advertiser"), + subject: bytes("subject"), + }) +} diff --git a/src/uuid_v7.rs b/src/uuid_v7.rs new file mode 100644 index 0000000..dfbfd0d --- /dev/null +++ b/src/uuid_v7.rs @@ -0,0 +1,22 @@ +//! UUID v7s, the frame ids and record versions macula 12 carries: 48 bits of +//! Unix milliseconds, the version and variant bits, and 74 random bits. + +/// Now, in Unix milliseconds. +pub(crate) fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// A new UUID v7. +pub(crate) fn new() -> [u8; 16] { + let mut id = [0u8; 16]; + // An id is an identifier, not a secret: one without randomness is still + // ordered and unique by its time, so a failure to draw is not an error. + let _ = aws_lc_rs::rand::fill(&mut id[6..]); + id[..6].copy_from_slice(&now_ms().to_be_bytes()[2..]); + id[6] = (id[6] & 0x0f) | 0x70; + id[8] = (id[8] & 0x3f) | 0x80; + id +} diff --git a/tests/record.rs b/tests/record.rs new file mode 100644 index 0000000..a7e469c --- /dev/null +++ b/tests/record.rs @@ -0,0 +1,477 @@ +//! macula 12's DHT records: signed under MACULA-PQ-RECORD-V1, verified in the +//! design's order, stored under their storage keys, and a procedure's provider +//! authorization (D25): an org directory and a procedure delegation, or a +//! node's own namespace, held to the verdicts macula reaches on the shared +//! fixtures (tests/vectors/record/own_namespace). + +use macula_rust::cbor::{self, Value}; +use macula_rust::node_key::{key_id_of, NodeKey, Purpose}; +use macula_rust::profile::Profile; +use macula_rust::record::{ + encode, envelope, new_content_announcement, new_node_record, new_procedure_advertisement, + new_station_endpoint, new_tombstone, own_namespace, own_procedure, procedure_key, + read_node_record, read_procedure_advertisement, read_station_endpoint, read_tombstone, sign, + station_endpoint_key, storage_key, verify, verify_authorization, Authorization, + ContentAnnouncementOptions, NodeRecordOptions, ProcedureAdvertisementOptions, Reason, + RecordError, RecordType, StationEndpointOptions, TombstoneOptions, Trust, Verified, +}; +use macula_rust::signed_object::sign_object; + +const P: Profile = Profile::PqPure; +const MINUTE: u64 = 60_000; +const HOUR: u64 = 60 * MINUTE; + +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 +} + +fn identity() -> NodeKey { + NodeKey::generate(Purpose::Identity, P).unwrap() +} + +fn verified(wire: &[u8]) -> Verified { + verify(wire, P, now() as i64).unwrap() +} + +#[test] +fn a_node_record_is_signed_by_its_node_and_stored_under_its_node_id() { + let key = identity(); + let node_id = key.node_id().unwrap(); + let opts = NodeRecordOptions { + display_name: "a node".into(), + lat: Some(50.8), + lng: Some(4.95), + peers: vec![[2; 32], [1; 32], [2; 32]], + ..NodeRecordOptions::default() + }; + let record = new_node_record(&node_id, &[[3; 32]], 5, &opts).unwrap(); + let signed = sign(&record, &key).unwrap(); + let v = verified(&encode(&signed).unwrap()); + let node = read_node_record(v.record()).unwrap(); + assert_eq!(node.node_id, node_id); + assert_eq!(node.station_id, node_id); + assert_eq!(node.realms, vec![[3; 32]]); + assert_eq!(node.capabilities, 5); + assert_eq!(node.display_name, "a node"); + assert_eq!(node.lat, Some(50.8)); + assert_eq!(node.peers, vec![[1; 32], [2; 32]]); + assert_eq!(storage_key(v.record()).unwrap(), node_id); + + // A connect key does not sign a node record; another node's is refused. + let connect = NodeKey::generate(Purpose::Connect, P).unwrap(); + assert!(matches!( + sign(&record, &connect), + Err(RecordError::KeyPurposeMismatch) + )); + let other = new_node_record(&[9; 32], &[], 0, &NodeRecordOptions::default()).unwrap(); + assert!(matches!( + sign(&other, &key), + Err(RecordError::KeyIdMismatch) + )); + let bad_geo = NodeRecordOptions { + lat: Some(91.0), + ..NodeRecordOptions::default() + }; + assert!(matches!( + new_node_record(&node_id, &[], 0, &bad_geo), + Err(RecordError::InvalidCoordinate(_)) + )); +} + +#[test] +fn a_procedure_advertisement_lives_five_minutes_under_its_procedure_key() { + let key = identity(); + let node_id = key.node_id().unwrap(); + let ad = new_procedure_advertisement( + &node_id, + &[3; 32], + "acme/echo", + &[4; 32], + &ProcedureAdvertisementOptions::default(), + ) + .unwrap(); + assert_eq!(ad.expires_at - ad.created_at, 5 * MINUTE); + let v = verified(&encode(&sign(&ad, &key).unwrap()).unwrap()); + let read = read_procedure_advertisement(v.record()).unwrap(); + assert_eq!(read.procedure, "acme/echo"); + assert_eq!(read.serving_station, [4; 32]); + assert_eq!(read.authorization, Authorization::None); + assert_eq!( + storage_key(v.record()).unwrap(), + procedure_key(&[3; 32], "acme/echo") + ); + let long = new_procedure_advertisement( + &node_id, + &[3; 32], + "acme/echo", + &[4; 32], + &ProcedureAdvertisementOptions { + ttl_ms: 5 * MINUTE + 1, + ..Default::default() + }, + ) + .unwrap(); + assert!(matches!( + sign(&long, &key), + Err(RecordError::LifetimeTooLong) + )); +} + +#[test] +fn a_station_endpoint_is_stored_under_its_station() { + let key = identity(); + let endpoint = new_station_endpoint( + 4433, + &StationEndpointOptions { + host_advertised: vec!["2001:db8::1".into()], + alpn: "macula".into(), + ttl_ms: 0, + }, + ) + .unwrap(); + let v = verified(&encode(&sign(&endpoint, &key).unwrap()).unwrap()); + let read = read_station_endpoint(v.record()).unwrap(); + assert_eq!(read.quic_port, 4433); + assert_eq!(read.host_advertised, vec!["2001:db8::1".to_string()]); + assert_eq!( + storage_key(v.record()).unwrap(), + station_endpoint_key(&key.node_id().unwrap()) + ); + assert!(matches!( + new_station_endpoint(0, &StationEndpointOptions::default()), + Err(RecordError::InvalidPort) + )); +} + +#[test] +fn a_record_is_refused_for_its_time_its_size_and_any_change() { + let key = identity(); + let node_id = key.node_id().unwrap(); + let signed = sign( + &new_node_record(&node_id, &[], 0, &NodeRecordOptions::default()).unwrap(), + &key, + ) + .unwrap(); + let wire = encode(&signed).unwrap(); + let created = signed.created_at as i64; + let expires = signed.expires_at as i64; + let tolerance = 5 * MINUTE as i64; + assert!(verify(&wire, P, created - tolerance).is_ok()); + assert!(matches!( + verify(&wire, P, created - tolerance - 1), + Err(RecordError::NotYetValid) + )); + assert!(verify(&wire, P, expires + tolerance).is_ok()); + assert!(matches!( + verify(&wire, P, expires + tolerance + 1), + Err(RecordError::Expired) + )); + assert!(matches!( + verify(&vec![0u8; 256 * 1024 + 1], P, created), + Err(RecordError::TooLarge) + )); + let mut altered = wire.clone(); + let last = altered.len() - 20; + altered[last] ^= 1; + assert!(matches!( + verify(&altered, P, created), + Err(RecordError::SignatureInvalid) + )); + assert!(matches!( + verify(&wire, Profile::PqHybrid, created), + Err(RecordError::Malformed(_)) + )); +} + +#[test] +fn a_tombstone_withdraws_a_record_from_its_slot() { + let key = identity(); + let node_id = key.node_id().unwrap(); + let ad = sign( + &new_procedure_advertisement( + &node_id, + &[3; 32], + "acme/echo", + &[4; 32], + &Default::default(), + ) + .unwrap(), + &key, + ) + .unwrap(); + let tombstone = sign( + &new_tombstone(&ad, Reason::Shutdown, &TombstoneOptions::default()).unwrap(), + &key, + ) + .unwrap(); + let v = verified(&encode(&tombstone).unwrap()); + let read = read_tombstone(v.record()).unwrap(); + assert_eq!(read.withdrawn_type, RecordType::PROCEDURE_ADVERTISEMENT); + assert_eq!(read.withdrawn_version, ad.version); + assert_eq!(read.procedure, "acme/echo"); + assert_eq!(storage_key(v.record()).unwrap(), storage_key(&ad).unwrap()); + assert!(matches!( + new_tombstone(&tombstone, Reason::Shutdown, &TombstoneOptions::default()), + Err(RecordError::TombstoneOfATombstone) + )); +} + +#[test] +fn a_content_announcement_needs_a_content_id_and_a_procedure() { + let key = identity(); + let node_id = key.node_id().unwrap(); + let mut mcid = vec![2u8, 0x55]; + mcid.extend([7u8; 48]); + let opts = ContentAnnouncementOptions { + realm_id: [3; 32], + serving_station: [4; 32], + procedure: own_procedure(&node_id, "content_v1"), + ..Default::default() + }; + let announcement = new_content_announcement(&node_id, &mcid, &opts).unwrap(); + verified(&encode(&sign(&announcement, &key).unwrap()).unwrap()); + assert!(matches!( + new_content_announcement(&node_id, &mcid[..49], &opts), + Err(RecordError::NotAContentId) + )); + let no_procedure = ContentAnnouncementOptions { + procedure: String::new(), + ..opts + }; + assert!(matches!( + new_content_announcement(&node_id, &mcid, &no_procedure), + Err(RecordError::Malformed(_)) + )); +} + +#[test] +fn a_domain_envelope_is_for_a_domain_type_with_a_subject_that_is_not_empty() { + let key = identity(); + let record = envelope(0x40, Value::Map(vec![]), Some(b"a subject".to_vec()), 0).unwrap(); + let v = verified(&encode(&sign(&record, &key).unwrap()).unwrap()); + assert_eq!(v.record().subject.as_deref(), Some(&b"a subject"[..])); + assert!(matches!( + envelope(0x06, Value::Map(vec![]), None, 0), + Err(RecordError::NotADomainType) + )); + assert!(matches!( + envelope(0x40, Value::Map(vec![]), Some(Vec::new()), 0), + Err(RecordError::InvalidSubject) + )); +} + +/// The shared fixtures: advertisements in a node's own namespace, and the +/// verdicts macula reaches on each. +#[test] +fn own_namespace_fixtures_reach_macula_s_verdicts() { + let text = std::fs::read_to_string("tests/vectors/record/own_namespace/verdicts.json").unwrap(); + let verdicts: serde_json::Value = serde_json::from_str(&text).unwrap(); + let verdicts = verdicts.as_array().unwrap(); + assert_eq!(verdicts.len(), 14); + let reason = |r: Result<(), RecordError>| match r { + Ok(()) => "ok".to_string(), + Err(RecordError::NotOwnNamespace) => "not_own_namespace".into(), + Err(RecordError::AuthorizationNotAllowed) => "authorization_not_allowed".into(), + Err(RecordError::Malformed(_)) => "malformed".into(), + Err(RecordError::NoAuthorization) => "no_authorization".into(), + Err(other) => format!("{other:?}"), + }; + for v in verdicts { + let profile = Profile::parse(v["profile"].as_str().unwrap()).unwrap(); + let wire = std::fs::read(format!( + "tests/vectors/record/own_namespace/{}", + v["file"].as_str().unwrap() + )) + .unwrap(); + let now_ms = v["now_ms"].as_i64().unwrap(); + let record = verify(&wire, profile, now_ms).unwrap(); + assert_eq!( + read_procedure_advertisement(record.record()) + .unwrap() + .procedure, + v["procedure"].as_str().unwrap() + ); + let file = v["file"].as_str().unwrap(); + assert_eq!( + reason(own_namespace(&record)), + v["own_namespace"].as_str().unwrap(), + "{file} own_namespace" + ); + let trust = Trust { + profile, + realm_key: None, + }; + assert_eq!( + reason(verify_authorization(&record, &trust, now_ms)), + v["verify_authorization"].as_str().unwrap(), + "{file} verify_authorization" + ); + } +} + +/// A record signed by hand, as the realm and org sign theirs: a signed object +/// under the record label with the record's fields. +fn hand_signed( + key: &NodeKey, + record_type: u8, + payload: Value, + created: u64, + lifetime: u64, +) -> Vec { + let mut version = [0u8; 16]; + version[0] = record_type; + version[15] = (created % 251) as u8; + let fields = vec![ + (Value::text("type"), Value::Int(i128::from(record_type))), + (Value::text("version"), Value::Bytes(version.to_vec())), + (Value::text("created_at"), Value::Int(i128::from(created))), + ( + Value::text("expires_at"), + Value::Int(i128::from(created + lifetime)), + ), + (Value::text("payload"), payload), + ]; + cbor::encode( + &sign_object("MACULA-PQ-RECORD-V1", &fields, key) + .unwrap() + .to_value(), + ) + .unwrap() +} + +struct Chain { + realm: NodeKey, + node: NodeKey, + advertisement: Verified, +} + +/// A realm that names the org acme, held by an org key that delegates to a +/// node, which advertises acme/echo with that authorization: `org_name` is the +/// org the directory names, `delegate_to_other` delegates to another node, +/// and the directory lives `directory_ms`. +fn chain(org_name: &str, delegate_to_other: bool, directory_ms: u64) -> Chain { + let (realm, org, node) = (identity(), identity(), identity()); + let t = now(); + let node_id = node.node_id().unwrap(); + let realm_id = [3u8; 32]; + let org_key_id = key_id_of(&org.public_key(), P); + let directory = hand_signed( + &realm, + RecordType::ORG_DIRECTORY.0, + Value::Map(vec![ + (Value::text("realm_id"), Value::Bytes(realm_id.to_vec())), + (Value::text("org_name"), Value::text(org_name)), + (Value::text("org_key"), Value::Bytes(org_key_id.to_vec())), + ]), + t, + directory_ms, + ); + let advertiser = if delegate_to_other { + [8u8; 32] + } else { + node_id + }; + let delegation = hand_signed( + &org, + RecordType::PROCEDURE_DELEGATION.0, + Value::Map(vec![ + (Value::text("org_key"), Value::Bytes(org_key_id.to_vec())), + (Value::text("advertiser"), Value::Bytes(advertiser.to_vec())), + ]), + t, + 6 * HOUR, + ); + let ad = new_procedure_advertisement( + &node_id, + &realm_id, + "acme/echo", + &[4; 32], + &ProcedureAdvertisementOptions { + authorization: Authorization::Delegation { + org_directory: directory, + procedure_delegation: delegation, + }, + ttl_ms: 0, + }, + ) + .unwrap(); + let advertisement = verified(&encode(&sign(&ad, &node).unwrap()).unwrap()); + Chain { + realm, + node, + advertisement, + } +} + +#[test] +fn an_org_procedure_is_authorized_by_the_realm_s_org_directory_and_the_org_s_delegation() { + let c = chain("acme", false, 6 * HOUR); + let trust = |key: Option>| Trust { + profile: P, + realm_key: key, + }; + let t = now() as i64; + assert!(verify_authorization(&c.advertisement, &trust(Some(c.realm.public_key())), t).is_ok()); + assert!(matches!( + verify_authorization(&c.advertisement, &trust(None), t), + Err(RecordError::NoRealmKey) + )); + assert!(matches!( + verify_authorization(&c.advertisement, &trust(Some(c.node.public_key())), t), + Err(RecordError::OrgDirectoryWrongRealm) + )); + + let other_org = chain("globex", false, 6 * HOUR); + assert!(matches!( + verify_authorization( + &other_org.advertisement, + &trust(Some(other_org.realm.public_key())), + t + ), + Err(RecordError::OrgDirectoryWrongOrg) + )); + let other_node = chain("acme", true, 6 * HOUR); + assert!(matches!( + verify_authorization( + &other_node.advertisement, + &trust(Some(other_node.realm.public_key())), + t + ), + Err(RecordError::DelegationMismatch) + )); + // A directory that lapses a minute from now, before the advertisement. + let short = chain("acme", false, MINUTE); + assert!(matches!( + verify_authorization( + &short.advertisement, + &trust(Some(short.realm.public_key())), + t + ), + Err(RecordError::AuthorizationOutlived) + )); +} + +#[test] +fn an_org_procedure_without_an_authorization_is_refused() { + let key = identity(); + let ad = new_procedure_advertisement( + &key.node_id().unwrap(), + &[3; 32], + "acme/echo", + &[4; 32], + &Default::default(), + ) + .unwrap(); + let v = verified(&encode(&sign(&ad, &key).unwrap()).unwrap()); + let trust = Trust { + profile: P, + realm_key: Some(identity().public_key()), + }; + assert!(matches!( + verify_authorization(&v, &trust, now() as i64), + Err(RecordError::NoAuthorization) + )); +} From a2c114c347e34859f1e566f9f4dad86b40e4a696 Mon Sep 17 00:00:00 2001 From: beamologist Date: Sat, 26 Sep 2026 08:59:47 +0200 Subject: [PATCH 08/12] station_link: a client's link to one macula 12 station The statement issuer (D22): a CONNECT key bound to the identity key, a status statement reissued every 15 minutes, rotation every 5 days, and CONNECT material in force for every dial. The link, ported from macula-go v0.12.0's stationlink: the v4 handshake over QUIC on macula-pqc, status statements both ways with the 5-minute grace, neighbour signatures and seqs in pq_hybrid, the _macula.ping liveness probe, calls, the DHT with every record verified, pubsub with per-node dedup, serving under an org (D25 directory and delegation, checked against the realm key) or in the node's own namespace, request admission, and streaming sessions released on every path. Tested against macula-go's in-process stations (tests/teststation, built by scripts/build-teststation.sh). close() now ends the link as Closed even when the connection watcher sees the station's close first. Co-Authored-By: Claude Opus 5.5 --- scripts/build-teststation.sh | 11 + src/lib.rs | 2 + src/statement_issuer.rs | 440 ++++++++++++++++++++ src/station_link.rs | 633 ++++++++++++++++++++++++++++ src/station_link/admission.rs | 298 +++++++++++++ src/station_link/call.rs | 208 ++++++++++ src/station_link/dht.rs | 91 ++++ src/station_link/framing.rs | 70 ++++ src/station_link/pubsub.rs | 301 ++++++++++++++ src/station_link/serve.rs | 539 ++++++++++++++++++++++++ src/station_link/stream.rs | 760 ++++++++++++++++++++++++++++++++++ tests/common/mod.rs | 130 ++++++ tests/statement_issuer.rs | 107 +++++ tests/station_link.rs | 357 ++++++++++++++++ tests/teststation/go.mod | 13 + tests/teststation/go.sum | 20 + tests/teststation/main.go | 94 +++++ 17 files changed, 4074 insertions(+) create mode 100755 scripts/build-teststation.sh create mode 100644 src/statement_issuer.rs create mode 100644 src/station_link.rs create mode 100644 src/station_link/admission.rs create mode 100644 src/station_link/call.rs create mode 100644 src/station_link/dht.rs create mode 100644 src/station_link/framing.rs create mode 100644 src/station_link/pubsub.rs create mode 100644 src/station_link/serve.rs create mode 100644 src/station_link/stream.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/statement_issuer.rs create mode 100644 tests/station_link.rs create mode 100644 tests/teststation/go.mod create mode 100644 tests/teststation/go.sum create mode 100644 tests/teststation/main.go diff --git a/scripts/build-teststation.sh b/scripts/build-teststation.sh new file mode 100755 index 0000000..e7a0a4d --- /dev/null +++ b/scripts/build-teststation.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Builds the in-process macula 12 test stations the integration tests dial +# (tests/teststation, macula-go's teststation) to target/teststation. cargo +# test does not build it: run this first, as CI does. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +export ASDF_GOLANG_VERSION="${ASDF_GOLANG_VERSION:-1.27.0}" +mkdir -p "$ROOT/target" +cd "$ROOT/tests/teststation" +go build -trimpath -o "$ROOT/target/teststation" . +echo "built $ROOT/target/teststation" diff --git a/src/lib.rs b/src/lib.rs index de7c519..09fb1f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,5 +15,7 @@ pub mod petname; pub mod profile; pub mod record; pub mod signed_object; +pub mod statement_issuer; +pub mod station_link; pub mod transport; mod uuid_v7; diff --git a/src/statement_issuer.rs b/src/statement_issuer.rs new file mode 100644 index 0000000..0ba1545 --- /dev/null +++ b/src/statement_issuer.rs @@ -0,0 +1,440 @@ +//! A client's status statement issuer, the client side of macula's +//! macula_statement_issuer (D22), as macula-go's StatementIssuer. It holds the +//! identity key, the node's CONNECT bindings with the newest status statement +//! for each, and the current CONNECT key. Each tick issues a statement valid +//! for an hour for each binding whose not_after has not passed, and hands it +//! to that binding's subscribers, the links that connected with it. Every 5 +//! days it rotates the CONNECT key: the new key's binding and statement exist +//! before [`StatementIssuer::connect_material`] hands the key out, and the +//! rotated-out binding keeps its statements until its not_after. +//! `connect_material` does work that is due itself, so a dial after missed +//! ticks, a sleep or a clock step still carries material in force. Nothing is +//! written to disk, so a new issuer starts with a new CONNECT key. + +use std::collections::HashMap; +use std::fmt; +use std::sync::{Arc, Mutex, Weak}; + +use sha2::{Digest, Sha384}; +use tokio::sync::Notify; + +use crate::binding::{connect_binding, status_statement, BindingError, SignedTbs}; +use crate::node_key::{KeyError, NodeKey, Purpose}; + +/// How often statements are reissued. +pub const STATEMENT_EVERY_MS: i64 = 15 * 60 * 1000; +/// How long a status statement is valid. +pub const STATEMENT_VALID_MS: i64 = 60 * 60 * 1000; +/// How long a CONNECT binding is valid. +pub const CONNECT_BINDING_VALID_MS: i64 = 7 * 24 * 60 * 60 * 1000; +/// How often the CONNECT key rotates. +pub const CONNECT_ROTATE_EVERY_MS: i64 = 5 * 24 * 60 * 60 * 1000; +/// How long before the current binding's not_after a failed rotation is +/// reported as overdue. +pub const ROTATION_MARGIN_MS: i64 = 24 * 60 * 60 * 1000; + +const TOLERANCE_MS: i64 = 5 * 60 * 1000; + +/// Why the issuer could not do what was asked. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IssuerError { + /// The key given is not an identity key. + NotAnIdentityKey, + /// A subscription to a binding the issuer does not hold, or whose + /// not_after has passed. + UnknownBinding, + /// No CONNECT binding and status statement in force, because the work + /// that renews them failed. + NoConnectMaterial(String), + /// A rotation failed while the current binding expires within the + /// rotation margin. + RotationOverdue { failures: u64, left_ms: i64 }, + /// A key could not be made or could not sign. + Key(KeyError), + /// A binding or statement could not be issued. + Binding(BindingError), +} + +impl fmt::Display for IssuerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + IssuerError::NotAnIdentityKey => f.write_str("the issuer needs an identity key"), + IssuerError::UnknownBinding => f.write_str("no binding in force has that hash"), + IssuerError::NoConnectMaterial(why) => write!(f, "no CONNECT binding and status statement in force: {why}"), + IssuerError::RotationOverdue { failures, left_ms } => write!( + f, + "the CONNECT key has not rotated: {failures} failed rotations, {left_ms} ms left on its binding" + ), + IssuerError::Key(e) => write!(f, "{e}"), + IssuerError::Binding(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for IssuerError {} + +impl From for IssuerError { + fn from(e: KeyError) -> Self { + IssuerError::Key(e) + } +} + +impl From for IssuerError { + fn from(e: BindingError) -> Self { + IssuerError::Binding(e) + } +} + +/// What a new dial carries: the CONNECT key, its binding, and a status +/// statement for that binding. +#[derive(Debug, Clone)] +pub struct ConnectMaterial { + pub key: Arc, + pub binding: SignedTbs, + pub status: SignedTbs, +} + +/// A clock in Unix milliseconds. +pub type Clock = Box i64 + Send + Sync>; + +/// A binding the issuer holds, with its newest statement, and its key while +/// it is the current binding. +struct StatedBinding { + key: Option>, + binding: SignedTbs, + statement: SignedTbs, + bound_at: i64, + stated_at: i64, + not_after: i64, +} + +impl StatedBinding { + fn rotation_due(&self, now: i64) -> bool { + now < self.bound_at - TOLERANCE_MS || now >= self.bound_at + CONNECT_ROTATE_EVERY_MS + } + + fn restatement_due(&self, now: i64) -> bool { + now < self.stated_at - TOLERANCE_MS || now >= self.stated_at + STATEMENT_EVERY_MS + } + + fn in_force(&self, now: i64) -> bool { + self.bound_at - TOLERANCE_MS <= now + && now <= self.not_after + && self.stated_at - TOLERANCE_MS <= now + && now < self.stated_at + STATEMENT_VALID_MS + } +} + +/// A subscription's slot: at most the newest statement, whether it closed, +/// and the waker of a reader waiting for one. +struct Slot { + newest: Mutex<(Option, bool)>, + notify: Notify, +} + +struct State { + identity: Arc, + clock: Clock, + current: [u8; 48], + bindings: HashMap<[u8; 48], StatedBinding>, + subscribers: HashMap<[u8; 48], Vec>>, + rotation_failures: u64, +} + +/// A client's statement issuer, shared by every link of one node. +#[derive(Clone)] +pub struct StatementIssuer { + state: Arc>, +} + +impl StatementIssuer { + /// An issuer for `identity`, reading the time from `clock`. It starts with + /// a new CONNECT key, bound and stated. + pub fn new(identity: Arc, clock: Clock) -> Result { + if identity.purpose() != Purpose::Identity { + return Err(IssuerError::NotAnIdentityKey); + } + let now = clock(); + let mut state = State { + identity, + clock, + current: [0; 48], + bindings: HashMap::new(), + subscribers: HashMap::new(), + rotation_failures: 0, + }; + state.rotate_connect(now)?; + Ok(StatementIssuer { + state: Arc::new(Mutex::new(state)), + }) + } + + /// An issuer on the wall clock. + pub fn with_wall_clock(identity: Arc) -> Result { + StatementIssuer::new(identity, Box::new(|| crate::uuid_v7::now_ms() as i64)) + } + + /// The current CONNECT key with its binding and a statement for it, both + /// in force at the clock's time. Work that is due is done first. + pub fn connect_material(&self) -> Result { + let mut state = self.lock(); + let now = (state.clock)(); + let mut work = Ok(()); + let due = state + .bindings + .get(&state.current) + .is_some_and(|b| b.rotation_due(now) || b.restatement_due(now)); + if due { + work = state.tick(now); + } + let current = &state.bindings[&state.current]; + match ¤t.key { + Some(key) if current.in_force(now) => Ok(ConnectMaterial { + key: key.clone(), + binding: current.binding.clone(), + status: current.statement.clone(), + }), + _ => Err(IssuerError::NoConnectMaterial(match work { + Err(e) => e.to_string(), + Ok(()) => "the current binding is out of force".into(), + })), + } + } + + /// How many rotations have failed since the last that succeeded. + pub fn rotation_failures(&self) -> u64 { + self.lock().rotation_failures + } + + /// Hands over the newest statement for `binding` at every reissue. The + /// subscription closes once a tick finds the binding's not_after passed, + /// and unsubscribes when dropped. + pub fn subscribe(&self, binding: &SignedTbs) -> Result { + let hash: [u8; 48] = Sha384::digest(&binding.tbs).into(); + let mut state = self.lock(); + let now = (state.clock)(); + match state.bindings.get(&hash) { + Some(held) if held.not_after >= now => {} + _ => return Err(IssuerError::UnknownBinding), + } + let slot = Arc::new(Slot { + newest: Mutex::new((None, false)), + notify: Notify::new(), + }); + state + .subscribers + .entry(hash) + .or_default() + .push(slot.clone()); + Ok(StatementSubscription { + slot, + hash, + issuer: Arc::downgrade(&self.state), + }) + } + + /// The periodic work at the clock's time: a statement for each binding in + /// force, a rotation when one is due, and the expired bindings let go. + pub fn tick(&self) -> Result<(), IssuerError> { + let mut state = self.lock(); + let now = (state.clock)(); + state.tick(now) + } + + /// Ticks every 15 minutes until every handle to the issuer is dropped, + /// handing a failed tick's error to `on_error`. + pub fn spawn_ticks( + &self, + on_error: impl Fn(IssuerError) + Send + 'static, + ) -> tokio::task::JoinHandle<()> { + let weak = Arc::downgrade(&self.state); + tokio::spawn(async move { + let mut ticks = + tokio::time::interval(std::time::Duration::from_millis(STATEMENT_EVERY_MS as u64)); + ticks.tick().await; + loop { + ticks.tick().await; + let Some(state) = weak.upgrade() else { return }; + let issuer = StatementIssuer { state }; + if let Err(e) = issuer.tick() { + on_error(e); + } + } + }) + } + + fn lock(&self) -> std::sync::MutexGuard<'_, State> { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +impl State { + fn tick(&mut self, now: i64) -> Result<(), IssuerError> { + let reissued = self.reissue(now); + let mut rotated = Ok(()); + if self + .bindings + .get(&self.current) + .is_some_and(|b| b.rotation_due(now)) + { + rotated = self.rotate(now); + } + self.drop_expired(now); + reissued.and(rotated) + } + + fn rotate(&mut self, now: i64) -> Result<(), IssuerError> { + match self.rotate_connect(now) { + Ok(()) => { + self.rotation_failures = 0; + Ok(()) + } + Err(e) => { + self.rotation_failures += 1; + let left = self + .bindings + .get(&self.current) + .map_or(0, |b| b.not_after - now); + if left < ROTATION_MARGIN_MS { + return Err(IssuerError::RotationOverdue { + failures: self.rotation_failures, + left_ms: left, + }); + } + Err(e) + } + } + } + + fn reissue(&mut self, now: i64) -> Result<(), IssuerError> { + let mut first_error = Ok(()); + for (hash, held) in self.bindings.iter_mut() { + if held.not_after < now { + continue; + } + match status_statement(&self.identity, &held.binding, now, now + STATEMENT_VALID_MS) { + Ok(statement) => { + held.statement = statement.clone(); + held.stated_at = now; + for slot in self.subscribers.get(hash).into_iter().flatten() { + deliver(slot, statement.clone()); + } + } + Err(e) => { + if first_error.is_ok() { + first_error = Err(e.into()); + } + } + } + } + first_error + } + + fn drop_expired(&mut self, now: i64) { + let expired: Vec<[u8; 48]> = self + .bindings + .iter() + .filter(|(_, b)| b.not_after < now) + .map(|(h, _)| *h) + .collect(); + for hash in expired { + for slot in self.subscribers.remove(&hash).into_iter().flatten() { + close(&slot); + } + if hash != self.current { + self.bindings.remove(&hash); + } + } + } + + fn rotate_connect(&mut self, now: i64) -> Result<(), IssuerError> { + let key = NodeKey::generate(Purpose::Connect, self.identity.profile())?; + let not_after = now + CONNECT_BINDING_VALID_MS; + let binding = connect_binding(&self.identity, &key.public_key(), now, not_after)?; + let statement = status_statement(&self.identity, &binding, now, now + STATEMENT_VALID_MS)?; + if let Some(previous) = self.bindings.get_mut(&self.current) { + previous.key = None; + } + let hash: [u8; 48] = Sha384::digest(&binding.tbs).into(); + self.bindings.insert( + hash, + StatedBinding { + key: Some(Arc::new(key)), + binding, + statement, + bound_at: now, + stated_at: now, + not_after, + }, + ); + self.current = hash; + Ok(()) + } +} + +fn deliver(slot: &Slot, statement: SignedTbs) { + let mut newest = slot.newest.lock().unwrap_or_else(|p| p.into_inner()); + newest.0 = Some(statement); + drop(newest); + slot.notify.notify_one(); +} + +fn close(slot: &Slot) { + slot.newest.lock().unwrap_or_else(|p| p.into_inner()).1 = true; + slot.notify.notify_one(); +} + +/// Why a subscription handed over no statement. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SubscriptionEmpty { + /// None has been issued since the last taken. + Empty, + /// The subscription has closed. + Closed, +} + +/// A subscription to one binding's statements, holding at most the newest. +pub struct StatementSubscription { + slot: Arc, + hash: [u8; 48], + issuer: Weak>, +} + +impl StatementSubscription { + /// The newest statement not yet taken. + pub fn try_recv(&mut self) -> Result { + let mut newest = self.slot.newest.lock().unwrap_or_else(|p| p.into_inner()); + match newest.0.take() { + Some(statement) => Ok(statement), + None if newest.1 => Err(SubscriptionEmpty::Closed), + None => Err(SubscriptionEmpty::Empty), + } + } + + /// The next statement, or `None` once the subscription has closed. + pub async fn recv(&mut self) -> Option { + let slot = self.slot.clone(); + loop { + let notified = slot.notify.notified(); + match self.try_recv() { + Ok(statement) => return Some(statement), + Err(SubscriptionEmpty::Closed) => return None, + Err(SubscriptionEmpty::Empty) => notified.await, + } + } + } +} + +impl Drop for StatementSubscription { + fn drop(&mut self) { + let Some(state) = self.issuer.upgrade() else { + return; + }; + let mut state = state.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(slots) = state.subscribers.get_mut(&self.hash) { + slots.retain(|s| !Arc::ptr_eq(s, &self.slot)); + } + } +} diff --git a/src/station_link.rs b/src/station_link.rs new file mode 100644 index 0000000..a6fdced --- /dev/null +++ b/src/station_link.rs @@ -0,0 +1,633 @@ +//! A client's link to one macula 12 station, as macula_station_link and +//! macula-go's stationlink are: a QUIC connection dialed to the station its +//! target pins, one bidirectional control stream, the v4 handshake on it, then +//! status statements both ways and every frame of the session. +//! +//! After HELLO the link sends its own status statement at every reissue of +//! the node's statement issuer, and ends when the station's statement is five +//! minutes past its expiry or the station's TLS binding reaches its +//! not_after. In pq_hybrid every control frame is neighbour-signed with a +//! sequence number per direction, from 0 after HELLO; a frame out of sequence +//! ends the link. A liveness probe, a `_macula.ping` call every 30 seconds, +//! ends the link after two misses in a row. + +mod admission; +mod call; +mod dht; +mod framing; +mod pubsub; +mod serve; +mod stream; + +pub use admission::{Admission, AdmissionLimits}; +pub use call::{Call, DEFAULT_CALL_TIMEOUT, MAX_CALL_TIMEOUT}; +pub use pubsub::{Event, EventDedup, Publication, PublicationSeq, SignedPublication, Subscription}; +pub use serve::{handler, BoxFuture, Handler, Offer, Request, Served, StreamOffer}; +pub use stream::{ + stream_handler, Stream, StreamCall, StreamEvent, StreamHandler, DEFAULT_STREAM_DEADLINE, +}; + +use std::collections::HashMap; +use std::fmt; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use sha2::{Digest, Sha384}; +use tokio::sync::watch; + +use crate::cbor::{self, Value}; +use crate::frame::{self, FrameError, NeighbourLink, NeighbourPeer}; +use crate::handshake::{self, ClientSession, HandshakeError, Peer, Station}; +use crate::node_key::NodeKey; +use crate::profile::Profile; +use crate::record::RecordError; +use crate::statement_issuer::{IssuerError, StatementIssuer, StatementSubscription}; +use crate::transport::{self, DialError, Target}; + +use framing::{read_frame, FrameWriter, HANDSHAKE_FRAME_BYTES, MAX_FRAME_BYTES}; + +/// How long the handshake may take, as macula's 30 seconds. +pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30); + +/// How long Close waits, after its GOODBYE, for the station to close the +/// connection before closing it itself. +const CLOSE_LINGER: Duration = Duration::from_secs(1); + +/// How long past a statement's expiry the link keeps a station whose next +/// statement has not arrived: macula's five minutes. +const STATUS_GRACE_MS: i64 = 5 * 60 * 1000; + +/// Why a link or one of its operations failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LinkError { + /// The configuration cannot be dialed: a key of another profile than the + /// target's, or admission limits macula would not start with. + InvalidConfig(String), + /// The dial failed. + Dial(String), + /// The handshake refused, or was refused. + Handshake(HandshakeError), + /// The handshake did not finish within [`HANDSHAKE_TIMEOUT`]. + HandshakeTimeout, + /// The issuer had no CONNECT material in force. + Issuer(IssuerError), + /// The QUIC connection or a stream failed. + Io(String), + /// A frame longer than the cap in force. + FrameTooLarge(usize), + /// A frame that could not be built, or one received that does not + /// verify. + Frame(FrameError), + /// A record that could not be built, or one found that does not verify. + Record(RecordError), + /// The station's status statement lapsed past the grace. + StatusExpired, + /// The station's TLS binding reached its not_after. + BindingExpired, + /// The link was closed by its owner. + Closed, + /// The station ended the link with a GOODBYE, for this reason. + Goodbye(String), + /// The station missed two liveness probes in a row. + LivenessLost, + /// No verified reply answered the call within its timeout. + CallTimeout, + /// A provider's ERROR for the call. + Provider { + responded_by: [u8; 32], + code: String, + detail: Option, + }, + /// A relay error the connected station reported for the call. + Relay { reported_by: [u8; 32], code: String }, + /// A find_record the station answered not_found. + RecordNotFound, + /// A station reply to a DHT call in a shape that call never answers with. + UnexpectedReply(String), + /// An offer without exactly one handler, or an org procedure's without + /// the realm key. + InvalidOffer, + /// A procedure without a namespace, which nobody can authorize. + NoOrg, + /// A procedure already served on this link. + AlreadyServed, + /// A served procedure withdrawn by its owner. + Stopped, + /// A stream ended by a STREAM_ERROR: the peer's, the station's relay + /// error (`relay`), or this side's own abort. + Stream { + code: String, + message: String, + relay: bool, + }, + /// A stream that ended normally. + EndOfStream, + /// A send on a stream this side has ended. + StreamClosed, + /// A STREAM_OPEN over the 1 MiB a peer reads of one. + StreamOpenTooLarge(usize), +} + +impl fmt::Display for LinkError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LinkError::Provider { + code, + detail: Some(d), + .. + } => write!(f, "the provider answered {code}: {d}"), + LinkError::Provider { code, .. } => write!(f, "the provider answered {code}"), + LinkError::Relay { code, .. } => { + write!(f, "the station could not relay the call: {code}") + } + LinkError::Stream { code, message, .. } if !message.is_empty() => { + write!(f, "stream error {code}: {message}") + } + LinkError::Stream { code, .. } => write!(f, "stream error {code}"), + LinkError::Handshake(e) => write!(f, "handshake: {e}"), + LinkError::Frame(e) => write!(f, "frame: {e}"), + LinkError::Record(e) => write!(f, "record: {e}"), + LinkError::Issuer(e) => write!(f, "{e}"), + LinkError::Goodbye(reason) => write!(f, "the station said goodbye: {reason}"), + other => write!(f, "{other:?}"), + } + } +} + +impl std::error::Error for LinkError {} + +impl From for LinkError { + fn from(e: FrameError) -> Self { + LinkError::Frame(e) + } +} + +impl From for LinkError { + fn from(e: RecordError) -> Self { + LinkError::Record(e) + } +} + +impl From for LinkError { + fn from(e: HandshakeError) -> Self { + LinkError::Handshake(e) + } +} + +impl From for LinkError { + fn from(e: DialError) -> Self { + LinkError::Dial(e.to_string()) + } +} + +/// What a link is dialed with: the station to reach, the node's identity +/// key, the statement issuer that holds its CONNECT key and statements, and +/// the realm membership endorsement to present, empty for none. Links of one +/// node share their publication seq, admission and dedup; a link given none +/// makes its own. +pub struct Config { + pub target: Target, + pub identity: Arc, + pub issuer: StatementIssuer, + pub member_endorsement: Vec, + pub publication_seq: Option>, + pub admission: Option>, + pub dedup: Option>, + /// This link's place in the admission: the station it dialed, host:port, + /// when `None`. + pub share: Option, +} + +impl Config { + /// A link's configuration with none of the shared parts. + pub fn new(target: Target, identity: Arc, issuer: StatementIssuer) -> Config { + Config { + target, + identity, + issuer, + member_endorsement: Vec::new(), + publication_seq: None, + admission: None, + dedup: None, + share: None, + } + } +} + +/// A handshaked link to one station. Cloning it shares the link. +#[derive(Clone)] +pub struct Link { + inner: Arc, +} + +struct Inner { + connection: quinn::Connection, + _endpoint: quinn::Endpoint, + control: FrameWriter, + profile: Profile, + key: Arc, + self_id: [u8; 32], + station: Station, + station_capabilities: u64, + connection_hash: [u8; 48], + /// Orders a neighbour signature's seq with its write. + send_seq: tokio::sync::Mutex, + status_deadline: AtomicI64, + publication_seq: Arc, + admission: Arc, + dedup: Arc, + share: String, + state: Mutex, + done_tx: watch::Sender, + done_rx: watch::Receiver, +} + +struct State { + ended: Option, + /// The owner is closing the link: however it then ends, it ends + /// [`LinkError::Closed`]. + closing: bool, + unrouted: HashMap, + pending: HashMap<[u8; 16], call::Pending>, + subs: HashMap<([u8; 32], String), Vec>, + served: HashMap<([u8; 32], String), serve::ServedEntry>, + streams: Vec>, +} + +impl Link { + /// Dials `cfg.target`, runs the v4 handshake as a client, and returns the + /// link once the station's HELLO accepts it, within + /// [`HANDSHAKE_TIMEOUT`]. + pub async fn dial(cfg: Config) -> Result { + if cfg.identity.profile() != cfg.target.profile { + return Err(LinkError::InvalidConfig( + "the identity key is of another profile than the target's".into(), + )); + } + if let Some(admission) = &cfg.admission { + admission.limits().validate()?; + } + tokio::time::timeout(HANDSHAKE_TIMEOUT, handshaken(cfg)) + .await + .map_err(|_| LinkError::HandshakeTimeout) + .and_then(|linked| linked) + } + + /// The node_id of the station the link reached. + pub fn station_node_id(&self) -> [u8; 32] { + self.inner.station.node_id + } + + /// The node_id this link connected as. + pub fn node_id(&self) -> [u8; 32] { + self.inner.self_id + } + + /// The capability bits the station's HELLO announced. + pub fn station_capabilities(&self) -> u64 { + self.inner.station_capabilities + } + + /// The profile the link runs. + pub fn profile(&self) -> Profile { + self.inner.profile + } + + /// Why the link ended, or `None` while it runs. + pub fn error(&self) -> Option { + self.inner.lock().ended.clone() + } + + /// Waits until the link has ended, and says why. + pub async fn done(&self) -> LinkError { + let mut done = self.inner.done_rx.clone(); + let _ = done.wait_for(|ended| *ended).await; + self.error().unwrap_or(LinkError::Closed) + } + + /// The frames received that nothing on this link handles, by what they + /// were. + pub fn unrouted(&self) -> HashMap { + self.inner.lock().unrouted.clone() + } + + /// Sends a GOODBYE with `reason`, closes the control stream's sending + /// side, waits up to a second for the station to close the connection, + /// and ends the link. Closing an ended link does nothing. + pub async fn close(&self, reason: &str) -> Result<(), LinkError> { + { + let mut state = self.inner.lock(); + if state.ended.is_some() { + return Ok(()); + } + state.closing = true; + } + let goodbye = frame::goodbye_frame(reason, None)?; + let sent = self.inner.send_control(&goodbye).await; + if sent.is_ok() { + self.inner.control.finish().await; + let _ = tokio::time::timeout(CLOSE_LINGER, self.inner.connection.closed()).await; + } + self.inner.end(LinkError::Closed); + sent + } +} + +async fn handshaken(cfg: Config) -> Result { + let dialed = transport::dial_target(&cfg.target).await?; + let (send, mut recv) = dialed + .connection + .open_bi() + .await + .map_err(|e| LinkError::Io(format!("open the control stream: {e}")))?; + let control = FrameWriter::new(send); + control + .write(&handshake::opener(), HANDSHAKE_FRAME_BYTES) + .await?; + let challenge = read_frame(&mut recv, HANDSHAKE_FRAME_BYTES).await?; + let material = cfg.issuer.connect_material().map_err(LinkError::Issuer)?; + let (connect, station) = handshake::answer_challenge( + &challenge, + &ClientSession { + profile: cfg.target.profile, + expected_node_id: cfg.target.expected_node_id, + leaf: &dialed.leaf, + identity_key: cfg.identity.public_key(), + connect_key: &material.key, + connect_binding: &material.binding, + connect_status: &material.status, + capabilities: 0, + now_ms: now_ms(), + member_endorsement: cfg.member_endorsement.clone(), + }, + )?; + control.write(&connect, HANDSHAKE_FRAME_BYTES).await?; + let hello = read_frame(&mut recv, HANDSHAKE_FRAME_BYTES).await?; + let capabilities = handshake::read_hello(&hello)?; + let self_id = cfg + .identity + .node_id() + .map_err(|e| LinkError::InvalidConfig(e.to_string()))?; + let statements = cfg + .issuer + .subscribe(&material.binding) + .map_err(LinkError::Issuer)?; + let (done_tx, done_rx) = watch::channel(false); + let share = cfg + .share + .unwrap_or_else(|| format!("{}:{}", cfg.target.host, cfg.target.port)); + let inner = Arc::new(Inner { + connection: dialed.connection, + _endpoint: dialed.endpoint, + control, + profile: cfg.target.profile, + key: cfg.identity, + self_id, + status_deadline: AtomicI64::new(station.status_expires_at + STATUS_GRACE_MS), + station_capabilities: capabilities, + connection_hash: Sha384::digest(&challenge).into(), + station, + send_seq: tokio::sync::Mutex::new(0), + publication_seq: cfg.publication_seq.unwrap_or_default(), + admission: cfg + .admission + .unwrap_or_else(|| Arc::new(Admission::new(AdmissionLimits::default()))), + dedup: cfg.dedup.unwrap_or_default(), + share, + state: Mutex::new(State { + ended: None, + closing: false, + unrouted: HashMap::new(), + pending: HashMap::new(), + subs: HashMap::new(), + served: HashMap::new(), + streams: Vec::new(), + }), + done_tx, + done_rx, + }); + tokio::spawn(send_statements(Arc::downgrade(&inner), statements)); + tokio::spawn(read_control(inner.clone(), recv)); + tokio::spawn(stream::accept_streams(Arc::downgrade(&inner))); + tokio::spawn(call::probe(Arc::downgrade(&inner))); + tokio::spawn(watch_expiries(Arc::downgrade(&inner))); + tokio::spawn(watch_connection(Arc::downgrade(&inner))); + Ok(Link { inner }) +} + +impl Inner { + fn lock(&self) -> MutexGuard<'_, State> { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn count(&self, what: &str) { + *self.lock().unrouted.entry(what.to_string()).or_default() += 1; + } + + /// A version-2 frame on the control stream, neighbour-signed with the + /// next seq when the profile signs its type. + async fn send_control(&self, v: &Value) -> Result<(), LinkError> { + let mut seq = self.send_seq.lock().await; + let signed = frame::sign_neighbour( + v, + &self.key, + &NeighbourLink { + connection: self.connection_hash, + seq: *seq, + }, + )?; + if frame::neighbour_signed(self.profile, &frame_type_of(v)) { + *seq += 1; + } + self.write_control(&signed).await + } + + /// A frame on the control stream as it is: a data frame, which carries + /// its own end-to-end signature and no neighbour signature. + async fn write_control(&self, v: &Value) -> Result<(), LinkError> { + let encoded = + cbor::encode(v).map_err(|e| LinkError::Frame(FrameError::Payload(e.to_string())))?; + self.control.write(&encoded, MAX_FRAME_BYTES).await + } + + /// Ends the link once, with `err`: pending calls, subscriptions, served + /// procedures and streams end with it, and the connection closes. + fn end(&self, err: LinkError) { + let (err, pending, subs, served, streams) = { + let mut state = self.lock(); + if state.ended.is_some() { + return; + } + let err = if state.closing { + LinkError::Closed + } else { + err + }; + state.ended = Some(err.clone()); + ( + err, + std::mem::take(&mut state.pending), + std::mem::take(&mut state.subs), + std::mem::take(&mut state.served), + std::mem::take(&mut state.streams), + ) + }; + for (_, p) in pending { + let _ = p.outcome.send(Err(err.clone())); + } + drop(subs); + for s in served.into_values() { + s.end(err.clone()); + } + for s in streams.into_iter().filter_map(|w| w.upgrade()) { + stream::StreamInner::end(&s, Some(err.clone())); + } + self.connection.close(0u32.into(), b"link ended"); + let _ = self.done_tx.send(true); + } +} + +/// Sends each statement the issuer reissues as a STATUS, until the link +/// ends. A STATUS carries no neighbour signature and takes no seq. +async fn send_statements(link: std::sync::Weak, mut statements: StatementSubscription) { + loop { + let Some(done) = link.upgrade().map(|l| l.done_rx.clone()) else { + return; + }; + let mut done = done; + let statement = tokio::select! { + _ = done.wait_for(|ended| *ended) => return, + statement = statements.recv() => statement, + }; + let (Some(statement), Some(inner)) = (statement, link.upgrade()) else { + return; + }; + if let Err(e) = inner + .control + .write(&handshake::status_frame(&statement), MAX_FRAME_BYTES) + .await + { + inner.end(e); + return; + } + } +} + +/// Reads the station's frames until the link ends. +async fn read_control(inner: Arc, mut recv: quinn::RecvStream) { + let mut recv_seq = 0u64; + let mut done = inner.done_rx.clone(); + loop { + let payload = tokio::select! { + _ = done.wait_for(|ended| *ended) => return, + payload = read_frame(&mut recv, MAX_FRAME_BYTES) => payload, + }; + let outcome = match payload { + Ok(payload) => received(&inner, &payload, &mut recv_seq), + Err(e) => Err(e), + }; + if let Err(e) = outcome { + inner.end(e); + return; + } + } +} + +/// One frame from the station: a STATUS renews the station's statement, +/// every other frame is opened from its neighbour signature at the next seq, +/// and a GOODBYE ends the link. +fn received(inner: &Arc, payload: &[u8], recv_seq: &mut u64) -> Result<(), LinkError> { + let v = cbor::decode(payload).map_err(|_| LinkError::Frame(FrameError::Malformed))?; + let frame_type = frame_type_of(&v); + if frame_type == "status" { + let expires_at = handshake::read_status( + payload, + &Peer { + profile: inner.profile, + identity_key: inner.station.identity_key.clone(), + binding: inner.station.tls_binding.clone(), + now_ms: now_ms(), + }, + )?; + inner + .status_deadline + .store(expires_at + STATUS_GRACE_MS, Ordering::SeqCst); + return Ok(()); + } + let opened = frame::verify_neighbour( + &v, + &NeighbourPeer { + profile: inner.profile, + peer_key: inner.station.identity_key.clone(), + connection: inner.connection_hash, + seq: *recv_seq, + }, + )?; + if frame::neighbour_signed(inner.profile, &frame_type) { + *recv_seq += 1; + } + match frame_type.as_str() { + "event" => pubsub::evented(inner, &opened), + "result" | "error" => call::replied(inner, &opened), + "call" => serve::called(inner, &opened), + "goodbye" => { + let reason = match opened.get("reason") { + Some(Value::Text(r)) => r.clone(), + _ => String::new(), + }; + return Err(LinkError::Goodbye(reason)); + } + _ => inner.count(&frame_type), + } + Ok(()) +} + +/// Ends the link when the station's statement lapses past the grace, or its +/// TLS binding reaches its not_after. +async fn watch_expiries(link: std::sync::Weak) { + loop { + let Some(inner) = link.upgrade() else { return }; + let now = now_ms(); + if now >= inner.station.binding_not_after { + inner.end(LinkError::BindingExpired); + return; + } + let status_deadline = inner.status_deadline.load(Ordering::SeqCst); + if now >= status_deadline { + inner.end(LinkError::StatusExpired); + return; + } + let wait = (status_deadline.min(inner.station.binding_not_after) - now).clamp(1, 60_000); + let mut done = inner.done_rx.clone(); + drop(inner); + tokio::select! { + _ = done.wait_for(|ended| *ended) => return, + _ = tokio::time::sleep(Duration::from_millis(wait as u64)) => {} + } + } +} + +/// Ends the link when its QUIC connection closes. +async fn watch_connection(link: std::sync::Weak) { + let Some(connection) = link.upgrade().map(|l| l.connection.clone()) else { + return; + }; + let cause = connection.closed().await; + if let Some(inner) = link.upgrade() { + inner.end(LinkError::Io(cause.to_string())); + } +} + +fn frame_type_of(v: &Value) -> String { + match v.get("frame_type") { + Some(Value::Text(t)) => t.clone(), + _ => String::new(), + } +} + +fn now_ms() -> i64 { + crate::uuid_v7::now_ms() as i64 +} diff --git a/src/station_link/admission.rs b/src/station_link/admission.rs new file mode 100644 index 0000000..f061fb6 --- /dev/null +++ b/src/station_link/admission.rs @@ -0,0 +1,298 @@ +//! Admission of the CALLs a provider node receives, as +//! macula_request_admission judges them: a request runs once, whichever of +//! the node's links it arrives on. Its deadline must lie between the +//! provider's clock minus 5 minutes and plus 10 minutes, and (caller, +//! request_id) must be new; the entry is kept until the deadline plus 5 +//! minutes. A copy with the same request hash gets the stored reply, or +//! request_copy while the first still runs; one with another hash is refused. +//! The entries are bounded, and a full bound refuses rather than evicts, in +//! this order: each caller holds at most `caller_quota` entries, each share +//! (one link's place: the station it dialed) at most `share`, and the +//! admission at most `cap`; stored replies take at most `reply_bytes` per +//! caller and `reply_bytes_total` in all. A reply past either is not kept, and +//! a copy of its request is refused reply_not_kept. + +use std::collections::HashMap; +use std::hash::Hash; +use std::sync::{Arc, Mutex, MutexGuard}; + +use crate::frame::VerifiedRequest; + +use super::LinkError; + +const DEADLINE_PAST_TOLERANCE_MS: i64 = 5 * 60_000; +const DEADLINE_AHEAD_MAX_MS: i64 = 10 * 60_000; +const KEPT_PAST_DEADLINE_MS: i64 = 5 * 60_000; + +/// An admission's bounds. The last four bound the streaming sessions a node +/// serves, as macula_stream_sessions does: sessions at once per caller and in +/// all, and the bytes their inboxes hold per caller and in all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AdmissionLimits { + pub caller_quota: usize, + pub share: usize, + pub cap: usize, + pub reply_bytes: usize, + pub reply_bytes_total: usize, + pub sessions_per_caller: usize, + pub sessions: usize, + pub inbox_bytes_per_caller: usize, + pub inbox_bytes: usize, +} + +impl Default for AdmissionLimits { + /// macula's defaults, with `cap` one share's worth: the bound of an + /// admission a single link holds. A pool sets `cap` to `share` times the + /// most links it holds. + fn default() -> Self { + AdmissionLimits { + caller_quota: 256, + share: 1024, + cap: 1024, + reply_bytes: 256 * 1024, + reply_bytes_total: 16 * 1024 * 1024, + sessions_per_caller: 16, + sessions: 1000, + inbox_bytes_per_caller: 16 * 1024 * 1024, + inbox_bytes: 256 * 1024 * 1024, + } + } +} + +impl AdmissionLimits { + /// Whether macula would start with these limits: every bound positive, + /// and each per-caller bound within its total. + pub fn validate(&self) -> Result<(), LinkError> { + let l = self; + let valid = l.caller_quota > 0 + && l.share > 0 + && l.cap > 0 + && l.reply_bytes > 0 + && l.reply_bytes_total > 0 + && l.caller_quota <= l.share + && l.reply_bytes <= l.reply_bytes_total + && l.sessions_per_caller > 0 + && l.sessions >= l.sessions_per_caller + && l.inbox_bytes_per_caller > 0 + && l.inbox_bytes >= l.inbox_bytes_per_caller; + if valid { + Ok(()) + } else { + Err(LinkError::InvalidConfig( + "admission limits must be positive, each per-caller bound within its total".into(), + )) + } + } +} + +/// The admission's judgement of a request: refused with a code, a copy with +/// its stored reply (`None` while the first still runs), or new. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Verdict { + Refused(&'static str), + Copy(Option>), + New, +} + +struct Entry { + hash: [u8; 48], + expires_at: i64, + share: String, + answered: bool, + /// `None` when answered but not kept. + reply: Option>, +} + +#[derive(Default)] +struct Held { + entries: HashMap<([u8; 32], [u8; 16]), Entry>, + callers: HashMap<[u8; 32], usize>, + shares: HashMap, + reply_bytes: HashMap<[u8; 32], usize>, + reply_total: usize, + sessions: HashMap<[u8; 32], usize>, + sessions_total: usize, + inbox: HashMap<[u8; 32], usize>, + inbox_total: usize, +} + +/// One provider node's request admission, shared by all its links. +pub struct Admission { + limits: AdmissionLimits, + held: Mutex, +} + +impl Admission { + /// An empty admission with `limits`, which must validate: a link given + /// limits that do not is refused at dial. + pub fn new(limits: AdmissionLimits) -> Admission { + Admission { + limits, + held: Mutex::new(Held::default()), + } + } + + /// The admission's bounds. + pub fn limits(&self) -> AdmissionLimits { + self.limits + } + + fn lock(&self) -> MutexGuard<'_, Held> { + self.held.lock().unwrap_or_else(|p| p.into_inner()) + } + + /// Judges `request` arriving on `share` at `now_ms`, sweeping the entries + /// that expired first. + pub(super) fn admit(&self, request: &VerifiedRequest, share: &str, now_ms: i64) -> Verdict { + let deadline = request.deadline as i64; + if deadline < now_ms - DEADLINE_PAST_TOLERANCE_MS { + return Verdict::Refused("expired"); + } + if deadline > now_ms + DEADLINE_AHEAD_MAX_MS { + return Verdict::Refused("not_yet_valid"); + } + let mut held = self.lock(); + held.sweep(now_ms); + let key = (request.caller, request.request_id); + if let Some(entry) = held.entries.get(&key) { + if entry.hash != request.request_hash { + return Verdict::Refused("request_id_reused"); + } + if entry.answered && entry.reply.is_none() { + return Verdict::Refused("reply_not_kept"); + } + return Verdict::Copy(entry.reply.clone()); + } + if held.callers.get(&request.caller).copied().unwrap_or(0) >= self.limits.caller_quota { + return Verdict::Refused("caller_quota"); + } + if held.shares.get(share).copied().unwrap_or(0) >= self.limits.share { + return Verdict::Refused("share_full"); + } + if held.entries.len() >= self.limits.cap { + return Verdict::Refused("admission_full"); + } + held.entries.insert( + key, + Entry { + hash: request.request_hash, + expires_at: deadline + KEPT_PAST_DEADLINE_MS, + share: share.to_string(), + answered: false, + reply: None, + }, + ); + *held.callers.entry(request.caller).or_default() += 1; + *held.shares.entry(share.to_string()).or_default() += 1; + Verdict::New + } + + /// Keeps the encoded reply of an admitted request for its copies, when + /// the byte bounds leave room for it. + pub(super) fn store(&self, request: &VerifiedRequest, reply: Vec) { + let mut held = self.lock(); + let held = &mut *held; + let Some(entry) = held.entries.get_mut(&(request.caller, request.request_id)) else { + return; + }; + if entry.answered || entry.hash != request.request_hash { + return; + } + entry.answered = true; + let caller_bytes = held.reply_bytes.get(&request.caller).copied().unwrap_or(0); + if caller_bytes + reply.len() > self.limits.reply_bytes + || held.reply_total + reply.len() > self.limits.reply_bytes_total + { + return; + } + *held.reply_bytes.entry(request.caller).or_default() += reply.len(); + held.reply_total += reply.len(); + entry.reply = Some(reply); + } + + /// Takes a streaming session's place for `caller`, or `None` when the + /// per-caller or node bound is full. Dropping the place gives it back. + pub(super) fn open_session(self: &Arc, caller: [u8; 32]) -> Option { + let mut held = self.lock(); + if held.sessions.get(&caller).copied().unwrap_or(0) >= self.limits.sessions_per_caller + || held.sessions_total >= self.limits.sessions + { + return None; + } + *held.sessions.entry(caller).or_default() += 1; + held.sessions_total += 1; + Some(SessionPlace { + admission: self.clone(), + caller, + }) + } + + /// Counts `n` more bytes that `caller`'s served streams hold unread, + /// refusing a charge past either bound. + pub(super) fn charge_inbox(&self, caller: [u8; 32], n: usize) -> bool { + let mut held = self.lock(); + if held.inbox.get(&caller).copied().unwrap_or(0) + n > self.limits.inbox_bytes_per_caller + || held.inbox_total + n > self.limits.inbox_bytes + { + return false; + } + *held.inbox.entry(caller).or_default() += n; + held.inbox_total += n; + true + } + + /// Gives back `n` bytes `caller`'s served streams no longer hold. + pub(super) fn release_inbox(&self, caller: [u8; 32], n: usize) { + let mut held = self.lock(); + decrement(&mut held.inbox, caller, n); + held.inbox_total = held.inbox_total.saturating_sub(n); + } +} + +/// A streaming session's place in the admission, given back when dropped. +pub(super) struct SessionPlace { + admission: Arc, + caller: [u8; 32], +} + +impl Drop for SessionPlace { + fn drop(&mut self) { + let mut held = self.admission.lock(); + decrement(&mut held.sessions, self.caller, 1); + held.sessions_total = held.sessions_total.saturating_sub(1); + } +} + +impl Held { + /// Removes the entries whose deadline plus 5 minutes passed before + /// `now_ms`. + fn sweep(&mut self, now_ms: i64) { + let expired: Vec<_> = self + .entries + .iter() + .filter(|(_, e)| e.expires_at < now_ms) + .map(|(k, _)| *k) + .collect(); + for key in expired { + let Some(entry) = self.entries.remove(&key) else { + continue; + }; + decrement(&mut self.callers, key.0, 1); + decrement(&mut self.shares, entry.share, 1); + if let Some(reply) = entry.reply { + decrement(&mut self.reply_bytes, key.0, reply.len()); + self.reply_total = self.reply_total.saturating_sub(reply.len()); + } + } + } +} + +/// Takes `n` from `m[k]`, dropping the key at zero. +fn decrement(m: &mut HashMap, k: K, n: usize) { + if let Some(v) = m.get_mut(&k) { + *v = v.saturating_sub(n); + if *v == 0 { + m.remove(&k); + } + } +} diff --git a/src/station_link/call.rs b/src/station_link/call.rs new file mode 100644 index 0000000..1c0e710 --- /dev/null +++ b/src/station_link/call.rs @@ -0,0 +1,208 @@ +//! Calls on a link: a CALL signed with the link's identity key, answered by +//! the RESULT or ERROR that verifies for it: a provider reply signed by its +//! target, or a relay error the connected station signed. A reply that does +//! not verify is counted and ignored, and the call keeps waiting, as macula's +//! link does. The liveness probe is a call too. + +use std::sync::{Arc, Weak}; +use std::time::Duration; + +use tokio::sync::oneshot; + +use crate::cbor::Value; +use crate::frame::{self, ReplyType, RequestSpec, VerifiedRequest}; + +use super::{now_ms, Inner, Link, LinkError}; + +/// macula's default timeout for a call. +pub const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(5); +/// The longest a call waits: the far edge of a provider's deadline window. +pub const MAX_CALL_TIMEOUT: Duration = Duration::from_secs(10 * 60); + +const LIVENESS_EVERY: Duration = Duration::from_secs(30); +const LIVENESS_TIMEOUT: Duration = Duration::from_secs(30); +const LIVENESS_PROCEDURE: &str = "_macula.ping"; + +/// One request: the realm and procedure, the node it targets (the station +/// itself when zero, as for `_dht.*`; the provider's node_id otherwise), its +/// payload, how long to wait (the default when zero), and a UCAN token and +/// its delegation chain's proofs for a gated procedure. +#[derive(Debug, Clone, PartialEq)] +pub struct Call { + pub realm: [u8; 32], + pub procedure: String, + pub target: [u8; 32], + pub payload: Value, + pub timeout: Duration, + pub token: Option>, + pub proofs: Vec>, +} + +impl Default for Call { + fn default() -> Self { + Call { + realm: [0; 32], + procedure: String::new(), + target: [0; 32], + payload: Value::Map(Vec::new()), + timeout: Duration::ZERO, + token: None, + proofs: Vec::new(), + } + } +} + +/// A call waiting for its reply. +pub(super) struct Pending { + pub(super) request: VerifiedRequest, + pub(super) outcome: oneshot::Sender>, +} + +impl Link { + /// Signs `c` as a CALL, sends it, and waits for the RESULT or ERROR that + /// verifies for it. + pub async fn call(&self, c: Call) -> Result { + call(&self.inner, c).await + } +} + +pub(super) async fn call(inner: &Arc, c: Call) -> Result { + let timeout = if c.timeout.is_zero() { + DEFAULT_CALL_TIMEOUT + } else { + c.timeout.min(MAX_CALL_TIMEOUT) + }; + let target = if c.target == [0; 32] { + inner.station.node_id + } else { + c.target + }; + let mut request_id = [0u8; 16]; + aws_lc_rs::rand::fill(&mut request_id).map_err(|_| LinkError::Io("no randomness".into()))?; + let signed = frame::sign_call( + &RequestSpec { + request_id, + realm: c.realm, + procedure: c.procedure, + target, + deadline: (now_ms() + timeout.as_millis() as i64) as u64, + payload: c.payload, + mode: None, + token: c.token, + proofs: c.proofs, + source_route: None, + retry_budget: None, + }, + &inner.key, + )?; + let request = frame::verify_request(&signed, inner.profile)?; + let (outcome_tx, outcome) = oneshot::channel(); + { + let mut state = inner.lock(); + if let Some(e) = &state.ended { + return Err(e.clone()); + } + state.pending.insert( + request_id, + Pending { + request, + outcome: outcome_tx, + }, + ); + } + let forget = || { + inner.lock().pending.remove(&request_id); + }; + if let Err(e) = inner.write_control(&signed).await { + forget(); + return Err(e); + } + let answered = tokio::time::timeout(timeout, outcome).await; + forget(); + match answered { + Ok(Ok(outcome)) => outcome, + Ok(Err(_)) => Err(inner.lock().ended.clone().unwrap_or(LinkError::Closed)), + Err(_) => Err(LinkError::CallTimeout), + } +} + +/// A RESULT or ERROR matched to its pending call by the ids it claims, and +/// handed on only once it verifies for that call's request. +pub(super) fn replied(inner: &Arc, v: &Value) { + let Ok((request_id, _)) = frame::claimed_reply_ids(v) else { + inner.count("malformed_reply"); + return; + }; + let request = match inner.lock().pending.get(&request_id) { + Some(p) => p.request.clone(), + None => { + inner.count("unmatched_reply"); + return; + } + }; + let Some(outcome) = verified_outcome(inner, v, &request) else { + inner.count("unverified_reply"); + return; + }; + if let Some(p) = inner.lock().pending.remove(&request_id) { + let _ = p.outcome.send(outcome); + } +} + +fn verified_outcome( + inner: &Inner, + v: &Value, + request: &VerifiedRequest, +) -> Option> { + if v.get("reply").is_some() { + let reply = frame::verify_reply(v, request, inner.profile).ok()?; + return Some(match reply.frame_type { + ReplyType::Result => Ok(reply.payload.unwrap_or(Value::Null)), + ReplyType::Error => Err(LinkError::Provider { + responded_by: reply.responded_by, + code: reply.code.unwrap_or_default(), + detail: reply.detail, + }), + }); + } + let relayed = + frame::verify_relay_error(v, request, inner.profile, &inner.station.node_id).ok()?; + Some(Err(LinkError::Relay { + reported_by: relayed.reported_by, + code: relayed.code, + })) +} + +/// A liveness probe every 30 seconds; two misses in a row end the link. Any +/// verified answer counts: it proves the station is there. +pub(super) async fn probe(link: Weak) { + let mut misses = 0; + loop { + let Some(mut done) = link.upgrade().map(|l| l.done_rx.clone()) else { + return; + }; + tokio::select! { + _ = done.wait_for(|ended| *ended) => return, + _ = tokio::time::sleep(LIVENESS_EVERY) => {} + } + let Some(inner) = link.upgrade() else { return }; + let outcome = call( + &inner, + Call { + procedure: LIVENESS_PROCEDURE.to_string(), + timeout: LIVENESS_TIMEOUT, + ..Call::default() + }, + ) + .await; + misses = if matches!(outcome, Err(LinkError::CallTimeout)) { + misses + 1 + } else { + 0 + }; + if misses >= 2 { + inner.end(LinkError::LivenessLost); + return; + } + } +} diff --git a/src/station_link/dht.rs b/src/station_link/dht.rs new file mode 100644 index 0000000..65868cc --- /dev/null +++ b/src/station_link/dht.rs @@ -0,0 +1,91 @@ +//! The DHT, as macula 12's facade reaches it: station procedures on the zero +//! realm, targeting the connected station, carrying record wire bytes. Every +//! record found is verified here before it is handed on, and one that does +//! not verify is dropped and counted. + +use crate::cbor::Value; +use crate::record::{self, RecordType, Verified}; + +use super::{now_ms, Call, Link, LinkError}; + +impl Link { + /// Stores a signed record, as its wire bytes, in the station's DHT. + pub async fn put_record(&self, wire: &[u8]) -> Result<(), LinkError> { + let result = self + .dht_call("_dht.put_record", Value::Bytes(wire.to_vec())) + .await?; + match result { + Value::Text(t) if t == "ok" => Ok(()), + other => Err(LinkError::UnexpectedReply(format!( + "put_record answered {other:?}" + ))), + } + } + + /// The record stored under `key`, verified. + pub async fn find_record(&self, key: &[u8; 32]) -> Result { + match self.dht_call("_dht.find_record", key_payload(key)).await? { + Value::Text(t) if t == "not_found" => Err(LinkError::RecordNotFound), + Value::Bytes(wire) => Ok(record::verify(&wire, self.profile(), now_ms())?), + other => Err(LinkError::UnexpectedReply(format!( + "find_record answered {other:?}" + ))), + } + } + + /// Every record stored under `key` that verifies, and how many the + /// station returned that did not. + pub async fn find_records(&self, key: &[u8; 32]) -> Result<(Vec, usize), LinkError> { + self.verified_list("_dht.find_records", key_payload(key)) + .await + } + + /// Every record of type `t` the station holds that verifies, and how many + /// it returned that did not. + pub async fn find_records_by_type( + &self, + t: RecordType, + ) -> Result<(Vec, usize), LinkError> { + let payload = Value::Map(vec![(Value::text("type"), Value::Int(i128::from(t.0)))]); + self.verified_list("_dht.find_records_by_type", payload) + .await + } + + async fn verified_list( + &self, + procedure: &str, + payload: Value, + ) -> Result<(Vec, usize), LinkError> { + let Value::List(items) = self.dht_call(procedure, payload).await? else { + return Err(LinkError::UnexpectedReply(format!( + "{procedure} answered no list" + ))); + }; + let now = now_ms(); + let mut verified = Vec::with_capacity(items.len()); + let mut dropped = 0; + for item in items { + match item { + Value::Bytes(wire) => match record::verify(&wire, self.profile(), now) { + Ok(r) => verified.push(r), + Err(_) => dropped += 1, + }, + _ => dropped += 1, + } + } + Ok((verified, dropped)) + } + + async fn dht_call(&self, procedure: &str, payload: Value) -> Result { + self.call(Call { + procedure: procedure.to_string(), + payload, + ..Call::default() + }) + .await + } +} + +fn key_payload(key: &[u8; 32]) -> Value { + Value::Map(vec![(Value::text("key"), Value::Bytes(key.to_vec()))]) +} diff --git a/src/station_link/framing.rs b/src/station_link/framing.rs new file mode 100644 index 0000000..e592fda --- /dev/null +++ b/src/station_link/framing.rs @@ -0,0 +1,70 @@ +//! A control stream's or a session stream's frames: ``, +//! with the caps macula 12 holds them to. A length header over the cap is +//! refused as soon as it arrives, before its body is read. + +use super::LinkError; + +/// A handshake frame (OPENER, CHALLENGE, CONNECT, HELLO) is at most 64 KiB. +pub(super) const HANDSHAKE_FRAME_BYTES: usize = 64 * 1024; + +/// Every frame after HELLO is at most 16 MiB. +pub(super) const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; + +/// The next frame's CBOR bytes, refusing a length header over `max`. +pub(super) async fn read_frame( + recv: &mut quinn::RecvStream, + max: usize, +) -> Result, LinkError> { + let mut header = [0u8; 4]; + recv.read_exact(&mut header) + .await + .map_err(|e| LinkError::Io(e.to_string()))?; + let length = u32::from_be_bytes(header) as usize; + if length > max { + return Err(LinkError::FrameTooLarge(length)); + } + let mut payload = vec![0u8; length]; + recv.read_exact(&mut payload) + .await + .map_err(|e| LinkError::Io(e.to_string()))?; + Ok(payload) +} + +/// A stream's sending side, writing one frame at a time. +pub(super) struct FrameWriter { + send: tokio::sync::Mutex, +} + +impl FrameWriter { + pub(super) fn new(send: quinn::SendStream) -> FrameWriter { + FrameWriter { + send: tokio::sync::Mutex::new(send), + } + } + + /// Sends `payload` as one frame, refusing one over `max`. + pub(super) async fn write(&self, payload: &[u8], max: usize) -> Result<(), LinkError> { + if payload.len() > max { + return Err(LinkError::FrameTooLarge(payload.len())); + } + let mut framed = Vec::with_capacity(4 + payload.len()); + framed.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + framed.extend_from_slice(payload); + self.send + .lock() + .await + .write_all(&framed) + .await + .map_err(|e| LinkError::Io(e.to_string())) + } + + /// Finishes the sending side gracefully, after what was written. + pub(super) async fn finish(&self) { + let _ = self.send.lock().await.finish(); + } + + /// Resets the sending side, dropping what it still holds. + pub(super) async fn reset(&self) { + let _ = self.send.lock().await.reset(0u32.into()); + } +} diff --git a/src/station_link/pubsub.rs b/src/station_link/pubsub.rs new file mode 100644 index 0000000..9c7fe59 --- /dev/null +++ b/src/station_link/pubsub.rs @@ -0,0 +1,301 @@ +//! PubSub, as macula 12's link does it. A PUBLISH carries a publication +//! signed with the link's identity key; SUBSCRIBE and UNSUBSCRIBE are control +//! frames naming the realm, the topic and this node, with no +//! acknowledgement; an EVENT carries a publication that is verified before it +//! is delivered, and delivered once however many copies arrive, until it +//! expires. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, Weak}; + +use tokio::sync::mpsc; + +use crate::cbor::Value; +use crate::frame::{self, PublicationSpec}; +use crate::node_key::NodeKey; + +use super::{now_ms, Inner, Link, LinkError}; + +/// How many events a subscription holds that its reader has not taken; an +/// event arriving at a full subscription is dropped and counted. +const EVENT_BUFFER: usize = 64; + +/// What [`Link::publish`] sends: the realm and topic, the payload, and its +/// time to live, `None` for macula's 10 minutes. +#[derive(Debug, Clone, PartialEq)] +pub struct Publication { + pub realm: [u8; 32], + pub topic: String, + pub payload: Value, + pub ttl_ms: Option, +} + +/// A publication a subscription heard, verified: who published it (the key +/// id its signature verified under), where, its seq and time, the payload, +/// and how it arrived (direct or plumtree). +#[derive(Debug, Clone, PartialEq)] +pub struct Event { + pub publisher: [u8; 32], + pub realm: [u8; 32], + pub topic: String, + pub seq: u64, + pub published_at: u64, + pub payload: Value, + pub delivered_via: String, +} + +/// Numbers one publisher's publications: the first is the wall clock in +/// microseconds, and each after is one more than the last, or the clock, +/// whichever is later, as macula_publication_seq does. Links of one identity +/// key share one, so their seqs never repeat. +#[derive(Debug, Default)] +pub struct PublicationSeq { + last: Mutex, +} + +impl PublicationSeq { + /// The seq for the next publication. + pub fn next(&self) -> u64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_micros() as u64) + .unwrap_or(0); + let mut last = self.last.lock().unwrap_or_else(|p| p.into_inner()); + *last = now.max(*last + 1); + *last + } +} + +/// Remembers the publications a node delivered, by publication hash, until +/// each expires, so an event heard on several links, or twice on one, is +/// delivered once. The links of one node share one. +#[derive(Debug, Default)] +pub struct EventDedup { + seen: Mutex>, +} + +impl EventDedup { + /// Whether `hash` is new at `now_ms`, remembering it until `expires_at`. + fn first(&self, hash: [u8; 48], expires_at: u64, now_ms: i64) -> bool { + let mut seen = self.seen.lock().unwrap_or_else(|p| p.into_inner()); + if seen.contains_key(&hash) { + return false; + } + seen.retain(|_, until| *until >= now_ms as u64); + seen.insert(hash, expires_at); + true + } +} + +/// A PUBLISH signed once, to be sent on several links of one node: every copy +/// is the same publication, so a subscriber hearing it on several links +/// delivers it once. +#[derive(Debug, Clone, PartialEq)] +pub struct SignedPublication { + frame: Value, +} + +impl SignedPublication { + /// Signs `p` with `key` at the next seq of `seq`. + pub fn sign( + key: &NodeKey, + seq: &PublicationSeq, + p: Publication, + ) -> Result { + let frame = frame::sign_publish( + &PublicationSpec { + realm: p.realm, + topic: p.topic, + seq: seq.next(), + published_at: now_ms() as u64, + payload: p.payload, + ttl_ms: p.ttl_ms, + }, + key, + )?; + Ok(SignedPublication { frame }) + } +} + +static NEXT_SUBSCRIBER: AtomicU64 = AtomicU64::new(1); + +/// One subscriber of a realm and topic on a link. +pub(super) struct SubscriberSlot { + id: u64, + events: mpsc::Sender, +} + +/// One subscription to a realm and topic on a link, until +/// [`Subscription::unsubscribe`] or the link ends. +pub struct Subscription { + link: Weak, + key: ([u8; 32], String), + id: u64, + events: mpsc::Receiver, + unsubscribed: bool, +} + +impl Subscription { + /// The next event, or `None` once the subscription has ended. + pub async fn recv(&mut self) -> Option { + self.events.recv().await + } + + /// Ends the subscription, and sends UNSUBSCRIBE once no other + /// subscription on the link holds its realm and topic. + pub async fn unsubscribe(mut self) -> Result<(), LinkError> { + self.unsubscribed = true; + let Some(inner) = self.link.upgrade() else { + return Ok(()); + }; + if drop_subscriber(&inner, &self.key, self.id) { + let frame = + frame::unsubscribe_frame(self.key.1.as_bytes(), &self.key.0, &inner.self_id)?; + inner.send_control(&frame).await?; + } + Ok(()) + } +} + +impl Drop for Subscription { + /// A subscription dropped without unsubscribing unsubscribes as it goes. + fn drop(&mut self) { + if self.unsubscribed { + return; + } + let Some(inner) = self.link.upgrade() else { + return; + }; + if !drop_subscriber(&inner, &self.key, self.id) { + return; + } + let key = self.key.clone(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + if let Ok(frame) = + frame::unsubscribe_frame(key.1.as_bytes(), &key.0, &inner.self_id) + { + let _ = inner.send_control(&frame).await; + } + }); + } + } +} + +/// Removes a subscriber, and whether it was the last on its realm and topic. +fn drop_subscriber(inner: &Inner, key: &([u8; 32], String), id: u64) -> bool { + let mut state = inner.lock(); + let Some(slots) = state.subs.get_mut(key) else { + return false; + }; + slots.retain(|s| s.id != id); + if slots.is_empty() { + state.subs.remove(key); + return true; + } + false +} + +impl Link { + /// Signs `p` as a PUBLISH and sends it. + pub async fn publish(&self, p: Publication) -> Result<(), LinkError> { + let signed = SignedPublication::sign(&self.inner.key, &self.inner.publication_seq, p)?; + self.publish_signed(&signed).await + } + + /// Sends a publication signed once for several links, as it is. + pub async fn publish_signed(&self, p: &SignedPublication) -> Result<(), LinkError> { + self.inner.write_control(&p.frame).await + } + + /// Subscribes to `topic` in `realm`: the first subscription to a realm + /// and topic on the link sends SUBSCRIBE. + pub async fn subscribe( + &self, + realm: &[u8; 32], + topic: &str, + ) -> Result { + let key = (*realm, topic.to_string()); + let (events_tx, events) = mpsc::channel(EVENT_BUFFER); + let id = NEXT_SUBSCRIBER.fetch_add(1, Ordering::Relaxed); + let first = { + let mut state = self.inner.lock(); + if let Some(e) = &state.ended { + return Err(e.clone()); + } + let slots = state.subs.entry(key.clone()).or_default(); + slots.push(SubscriberSlot { + id, + events: events_tx, + }); + slots.len() == 1 + }; + let subscription = Subscription { + link: Arc::downgrade(&self.inner), + key: key.clone(), + id, + events, + unsubscribed: false, + }; + if first { + let frame = frame::subscribe_frame(topic.as_bytes(), realm, &self.inner.self_id); + let sent = match frame { + Ok(frame) => self.inner.send_control(&frame).await, + Err(e) => Err(e.into()), + }; + if let Err(e) = sent { + drop_subscriber(&self.inner, &key, id); + let mut subscription = subscription; + subscription.unsubscribed = true; + return Err(e); + } + } + Ok(subscription) + } +} + +/// An EVENT's publication, once it verifies, delivered to every subscription +/// on its realm and topic, unless it was delivered before. +pub(super) fn evented(inner: &Arc, v: &Value) { + let now = now_ms(); + let Ok(publication) = frame::verify_publication(v, inner.profile, now) else { + inner.count("event_unverified"); + return; + }; + let delivered_via = match v.get("delivered_via") { + Some(Value::Text(t)) => t.clone(), + _ => String::new(), + }; + if !inner + .dedup + .first(publication.publication_hash, publication.expires_at, now) + { + inner.count("event_duplicate"); + return; + } + let event = Event { + publisher: publication.publisher, + realm: publication.realm, + topic: publication.topic.clone(), + seq: publication.seq, + published_at: publication.published_at, + payload: publication.payload, + delivered_via, + }; + let mut state = inner.lock(); + let Some(slots) = state.subs.get(&(publication.realm, publication.topic)) else { + *state + .unrouted + .entry("event_unsubscribed".into()) + .or_default() += 1; + return; + }; + let overflowed = slots + .iter() + .filter(|s| s.events.try_send(event.clone()).is_err()) + .count(); + if overflowed > 0 { + *state.unrouted.entry("event_overflow".into()).or_default() += overflowed as u64; + } +} diff --git a/src/station_link/serve.rs b/src/station_link/serve.rs new file mode 100644 index 0000000..dd7279e --- /dev/null +++ b/src/station_link/serve.rs @@ -0,0 +1,539 @@ +//! Serving, as macula 12's provider does it. A procedure is served under an +//! org namespace: the realm's org directory names the org's key, the org's +//! procedure delegation names this node, both are found in the DHT, and the +//! signed procedure_advertisement carries them to the station in an +//! ADVERTISE, checked against the realm key before it goes out. Or it is +//! served in this node's own namespace, `~/` (D25 item 6): its +//! advertisement carries no authorization, its signature alone authorizes it, +//! and no realm key or record in the DHT is needed. The station routes CALLs +//! for the procedure to this link; each is admitted once per (caller, +//! request_id) and answered with a RESULT or ERROR signed by this node. +//! UNADVERTISE carries a tombstone of the advertisement. +//! +//! Only open procedures are served: a gated one needs a post-quantum UCAN +//! verifier, which this crate does not have. + +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex, Weak}; +use std::time::Duration; + +use tokio::sync::watch; + +use crate::cbor::{self, Value}; +use crate::frame::{self, StreamMode, VerifiedRequest}; +use crate::record::{ + self, Authorization, ProcedureAdvertisementOptions, Reason, Record, RecordError, + TombstoneOptions, Trust, +}; + +use super::admission::Verdict; +use super::framing::MAX_FRAME_BYTES; +use super::stream::StreamHandler; +use super::{now_ms, Inner, Link, LinkError}; + +/// The provider codes of a served procedure's ERRORs: a handler's own +/// refusal, a handler that panicked, a procedure this link does not serve, a +/// copy of a request still running, and a result the wire cannot carry. +const CODE_HANDLER_ERROR: &str = "handler_error"; +const CODE_HANDLER_CRASHED: &str = "temporary_relay_failure"; +const CODE_UNKNOWN_PROCEDURE: &str = "unknown_next_peer"; +pub(super) const CODE_REQUEST_COPY: &str = "request_copy"; +const CODE_PAYLOAD_TOO_LARGE: &str = "payload_too_large"; +const CODE_UNSENDABLE: &str = "unknown_error"; + +/// An ERROR's detail is at most 256 bytes, cut on a character boundary. +const MAX_DETAIL_BYTES: usize = 256; + +/// macula's default and longest advertisement lifetime. +const MAX_ADVERTISEMENT_TTL: Duration = Duration::from_secs(5 * 60); +/// How soon a failed renewal is tried again, while the advertisement it +/// replaces still lives. +const REFRESH_RETRY: Duration = Duration::from_secs(10); + +/// A future a handler returns. +pub type BoxFuture = Pin + Send + 'static>>; + +/// Answers a [`Request`] with a result payload, or an error whose text the +/// caller receives as a handler_error's detail. A handler still running at +/// the request's deadline is dropped and answered handler_error. +pub type Handler = Arc BoxFuture> + Send + Sync>; + +/// A [`Handler`] from an async closure. +pub fn handler(f: F) -> Handler +where + F: Fn(Request) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, +{ + Arc::new(move |r| Box::pin(f(r))) +} + +/// A CALL a served procedure answers: the caller (the key id its signature +/// verified under), what it asked for, and its deadline in unix +/// milliseconds. +#[derive(Debug, Clone, PartialEq)] +pub struct Request { + pub caller: [u8; 32], + pub realm: [u8; 32], + pub procedure: String, + pub payload: Value, + pub token: Option>, + pub proofs: Option>>, + pub deadline_ms: u64, +} + +/// A procedure to serve: its realm and name, exactly one of a unary handler +/// and a stream offer, and, for an org procedure, the realm key the org +/// directory must be signed with, as the realm's members pin it; a procedure +/// in this node's own namespace needs none. +#[derive(Clone)] +pub struct Offer { + pub realm: [u8; 32], + pub procedure: String, + pub handler: Option, + pub stream: Option, + pub realm_key: Option>, +} + +impl Offer { + /// A unary procedure, with no realm key. + pub fn unary(realm: [u8; 32], procedure: &str, handler: Handler) -> Offer { + Offer { + realm, + procedure: procedure.to_string(), + handler: Some(handler), + stream: None, + realm_key: None, + } + } + + /// A streaming procedure of `mode`, with no realm key. + pub fn stream( + realm: [u8; 32], + procedure: &str, + mode: StreamMode, + handler: StreamHandler, + ) -> Offer { + Offer { + realm, + procedure: procedure.to_string(), + handler: None, + stream: Some(StreamOffer { mode, handler }), + realm_key: None, + } + } +} + +/// A streaming procedure's mode and handler. The advertisement is the one a +/// unary procedure sends, which names no mode: a STREAM_OPEN of another mode +/// is refused mode_mismatch. +#[derive(Clone)] +pub struct StreamOffer { + pub mode: StreamMode, + pub handler: StreamHandler, +} + +/// A procedure a link serves, as the link holds it. +pub(super) type ServedEntry = Arc; + +pub(super) struct ServedInner { + link: Weak, + key: ([u8; 32], String), + pub(super) offer: Offer, + latest: Mutex, + err: Mutex>, + done_tx: watch::Sender, +} + +/// A procedure this link serves, until [`Served::stop`] or the link ends, or +/// until its advertisement lapses because its authorization could not be +/// found again. +pub struct Served { + inner: Arc, +} + +impl Link { + /// Advertises `o`'s procedure on the link and answers its CALLs until + /// stopped. For an org procedure it resolves the org directory and this + /// node's procedure delegation from the DHT; a procedure in this node's + /// own namespace needs neither, and another node's namespace is refused. + /// It signs the advertisement with this node's identity key, naming the + /// connected station as the serving station and living no longer than + /// the records it carries nor 5 minutes, and checks its authorization + /// (against the realm key for an org procedure) before sending it in an + /// ADVERTISE and putting it in the DHT. The advertisement is renewed at + /// half its lifetime. + pub async fn serve(&self, o: Offer) -> Result { + let own = record::in_own_namespace(&o.procedure); + if o.handler.is_some() == o.stream.is_some() || (!own && o.realm_key.is_none()) { + return Err(LinkError::InvalidOffer); + } + if !matches!(record::procedure_org(&o.procedure), Ok(Some(_))) { + return Err(LinkError::NoOrg); + } + let (advertisement, wire) = self.advertisement(&o, MAX_ADVERTISEMENT_TTL).await?; + let key = (o.realm, o.procedure.clone()); + let (done_tx, _) = watch::channel(false); + let served = Arc::new(ServedInner { + link: Arc::downgrade(&self.inner), + key: key.clone(), + offer: o, + latest: Mutex::new(advertisement), + err: Mutex::new(None), + done_tx, + }); + { + let mut state = self.inner.lock(); + if let Some(e) = &state.ended { + return Err(e.clone()); + } + if state.served.contains_key(&key) { + return Err(LinkError::AlreadyServed); + } + state.served.insert(key, served.clone()); + } + if let Err(e) = self.announce(&wire).await { + served.end(e.clone()); + return Err(e); + } + tokio::spawn(renew(served.clone())); + Ok(Served { inner: served }) + } + + /// `o`'s signed procedure_advertisement and its wire form, living at most + /// `max_ttl`: with the authorization resolved from the DHT for an org + /// procedure, with none in this node's own namespace. + async fn advertisement( + &self, + o: &Offer, + max_ttl: Duration, + ) -> Result<(Record, Vec), LinkError> { + let inner = &self.inner; + let max_ttl_ms = max_ttl.as_millis() as u64; + let opts = if record::in_own_namespace(&o.procedure) { + ProcedureAdvertisementOptions { + authorization: Authorization::None, + ttl_ms: max_ttl_ms, + } + } else { + let org = record::procedure_org(&o.procedure)?.ok_or(LinkError::NoOrg)?; + let directory = self + .find_record(&record::org_directory_key(&o.realm, org)) + .await?; + let named = record::read_org_directory(directory.record())?; + let delegation = self + .find_record(&record::procedure_delegation_key( + &named.org_key, + &inner.self_id, + )) + .await?; + let now = now_ms(); + let ttl = (max_ttl_ms as i64) + .min(directory.record().expires_at as i64 - now) + .min(delegation.record().expires_at as i64 - now); + if ttl <= 0 { + return Err(RecordError::AuthorizationOutlived.into()); + } + ProcedureAdvertisementOptions { + authorization: Authorization::Delegation { + org_directory: record::encode(directory.record())?, + procedure_delegation: record::encode(delegation.record())?, + }, + ttl_ms: ttl as u64, + } + }; + let unsigned = record::new_procedure_advertisement( + &inner.self_id, + &o.realm, + &o.procedure, + &inner.station.node_id, + &opts, + )?; + // Signed, then checked as a caller will check it before it is sent + // anywhere. + let signed = record::sign(&unsigned, &inner.key)?; + let wire = record::encode(&signed)?; + let now = now_ms(); + let verified = record::verify(&wire, inner.profile, now)?; + record::verify_authorization( + &verified, + &Trust { + profile: inner.profile, + realm_key: o.realm_key.clone(), + }, + now, + )?; + Ok((signed, wire)) + } + + /// Sends an advertisement to the station in an ADVERTISE, which routes + /// CALLs through it, and puts it in the DHT, where a caller resolving + /// the procedure finds it, as macula's advertise_direct does both. + async fn announce(&self, wire: &[u8]) -> Result<(), LinkError> { + self.inner + .send_control(&frame::advertise_frame(wire)) + .await?; + self.put_record(wire).await + } +} + +impl Served { + /// Withdraws the advertisement with an UNADVERTISE carrying its + /// tombstone, signed by this node, puts the tombstone in the + /// advertisement's DHT slot, and stops answering the procedure's CALLs. + /// Stopping a procedure no longer served does nothing. + pub async fn stop(&self) -> Result<(), LinkError> { + if self.inner.is_done() { + return Ok(()); + } + self.inner.end(LinkError::Stopped); + let Some(inner) = self.inner.link.upgrade() else { + return Ok(()); + }; + let latest = self + .inner + .latest + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone(); + let tombstone = + record::new_tombstone(&latest, Reason::Shutdown, &TombstoneOptions::default())?; + let wire = record::encode(&record::sign(&tombstone, &inner.key)?)?; + inner.send_control(&frame::unadvertise_frame(&wire)).await?; + Link { inner }.put_record(&wire).await + } + + /// Waits until the procedure is no longer served, and says why. + pub async fn done(&self) -> LinkError { + let mut done = self.inner.done_tx.subscribe(); + let _ = done.wait_for(|ended| *ended).await; + self.error().unwrap_or(LinkError::Stopped) + } + + /// Why the procedure is no longer served: [`LinkError::Stopped`] after + /// stop, the link's error when it ended, or the renewal's failure; `None` + /// while served. + pub fn error(&self) -> Option { + self.inner + .err + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone() + } +} + +impl ServedInner { + fn is_done(&self) -> bool { + *self.done_tx.borrow() + } + + /// Ends the serving once, with `err`: the link no longer routes the + /// procedure's CALLs to its handler. + pub(super) fn end(self: &Arc, err: LinkError) { + { + let mut held = self.err.lock().unwrap_or_else(|p| p.into_inner()); + if held.is_some() { + return; + } + *held = Some(err); + } + if let Some(inner) = self.link.upgrade() { + let mut state = inner.lock(); + if state + .served + .get(&self.key) + .is_some_and(|s| Arc::ptr_eq(s, self)) + { + state.served.remove(&self.key); + } + } + let _ = self.done_tx.send(true); + } +} + +/// Sends a fresh advertisement at half the current one's lifetime. A renewal +/// that fails is tried again every [`REFRESH_RETRY`] while the current one +/// lives; when it lapses unrenewed, the procedure is no longer served and its +/// error says why. +async fn renew(served: Arc) { + let mut current = served + .latest + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone(); + let mut wait = half_life(¤t); + let mut last_err: Option = None; + let mut stopped = served.done_tx.subscribe(); + loop { + let Some(mut link_done) = served.link.upgrade().map(|l| l.done_rx.clone()) else { + return; + }; + tokio::select! { + _ = stopped.wait_for(|ended| *ended) => return, + _ = link_done.wait_for(|ended| *ended) => { + let err = served.link.upgrade().and_then(|l| l.lock().ended.clone()).unwrap_or(LinkError::Closed); + served.end(err); + return; + } + _ = tokio::time::sleep(wait) => {} + } + if now_ms() >= current.expires_at as i64 { + served.end(last_err.unwrap_or(LinkError::Stopped)); + return; + } + let Some(inner) = served.link.upgrade() else { + return; + }; + let link = Link { inner }; + let renewed = async { + let (advertisement, wire) = link + .advertisement(&served.offer, MAX_ADVERTISEMENT_TTL) + .await?; + link.announce(&wire).await?; + Ok::<_, LinkError>(advertisement) + } + .await; + match renewed { + Ok(advertisement) => { + *served.latest.lock().unwrap_or_else(|p| p.into_inner()) = advertisement.clone(); + wait = half_life(&advertisement); + current = advertisement; + } + Err(e) => { + last_err = Some(e); + let left = (current.expires_at as i64 - now_ms()).max(0) as u64; + wait = REFRESH_RETRY.min(Duration::from_millis(left)); + } + } + } +} + +fn half_life(r: &Record) -> Duration { + Duration::from_millis(r.expires_at.saturating_sub(r.created_at) / 2) +} + +/// Answers a CALL the station routed to this link. A request that does not +/// verify, or targets another node, gets no reply and is counted. One that +/// verifies is judged by the admission, then answered by its handler, or by +/// an ERROR naming why not. +pub(super) fn called(inner: &Arc, v: &Value) { + let Ok(request) = frame::verify_request(v, inner.profile) else { + inner.count("unverified_call"); + return; + }; + if request.target != inner.self_id { + inner.count("call_for_another_node"); + return; + } + let inner = inner.clone(); + match inner.admission.admit(&request, &inner.share, now_ms()) { + Verdict::Refused(code) => { + let reply = provider_error(&inner, &request, code, None); + tokio::spawn(async move { send_reply(&inner, reply).await }); + } + Verdict::Copy(None) => { + let reply = provider_error(&inner, &request, CODE_REQUEST_COPY, None); + tokio::spawn(async move { send_reply(&inner, reply).await }); + } + Verdict::Copy(Some(stored)) => { + tokio::spawn(async move { + let _ = inner.control.write(&stored, MAX_FRAME_BYTES).await; + }); + } + Verdict::New => { + tokio::spawn(answer(inner, request)); + } + } +} + +/// Runs the request's handler and sends its signed reply, storing it for the +/// request's copies. +async fn answer(inner: Arc, request: VerifiedRequest) { + let handler = inner + .lock() + .served + .get(&(request.realm, request.procedure.clone())) + .and_then(|s| s.offer.handler.clone()); + let reply = match handler { + None => provider_error(&inner, &request, CODE_UNKNOWN_PROCEDURE, None), + Some(handler) => handled(&inner, handler, &request).await, + }; + let Ok(encoded) = cbor::encode(&reply) else { + inner.count("unencodable_reply"); + return; + }; + inner.admission.store(&request, encoded.clone()); + let _ = inner.control.write(&encoded, MAX_FRAME_BYTES).await; +} + +/// The handler's answer to `request` as a signed reply: its result, its +/// refusal as handler_error, a panic as temporary_relay_failure, or a result +/// the wire cannot carry as payload_too_large or unknown_error. +async fn handled(inner: &Inner, handler: Handler, request: &VerifiedRequest) -> Value { + let running = tokio::spawn(handler(Request { + caller: request.caller, + realm: request.realm, + procedure: request.procedure.clone(), + payload: request.payload.clone(), + token: request.token.clone(), + proofs: request.proofs.clone(), + deadline_ms: request.deadline, + })); + let abort = running.abort_handle(); + let left = (request.deadline as i64 - now_ms()).max(0) as u64; + let outcome = tokio::time::timeout(Duration::from_millis(left), running).await; + match outcome { + Err(_) => { + abort.abort(); + provider_error( + inner, + request, + CODE_HANDLER_ERROR, + Some("the request's deadline passed"), + ) + } + Ok(Err(_panicked)) => provider_error(inner, request, CODE_HANDLER_CRASHED, None), + Ok(Ok(Err(refusal))) => provider_error( + inner, + request, + CODE_HANDLER_ERROR, + Some(bounded_detail(&refusal)), + ), + Ok(Ok(Ok(payload))) => match frame::sign_result(request, &payload, None, &inner.key) { + Ok(signed) => signed, + Err(_) if cbor::encode(&payload).is_ok_and(|e| e.len() > frame::MAX_FRAME_BYTES) => { + provider_error(inner, request, CODE_PAYLOAD_TOO_LARGE, None) + } + Err(_) => provider_error(inner, request, CODE_UNSENDABLE, None), + }, + } +} + +/// This node's signed ERROR for `request`. The request verified with this +/// node as its target, so signing cannot fail on the key; a code or detail +/// out of bounds is a bug in this module. +fn provider_error( + inner: &Inner, + request: &VerifiedRequest, + code: &str, + detail: Option<&str>, +) -> Value { + frame::sign_provider_error(request, code, detail, None, &inner.key) + .unwrap_or_else(|e| panic!("station_link: a provider error that does not sign: {e}")) +} + +async fn send_reply(inner: &Inner, reply: Value) { + let _ = inner.write_control(&reply).await; +} + +/// `text` cut to 256 bytes on a character boundary. +pub(super) fn bounded_detail(text: &str) -> &str { + if text.len() <= MAX_DETAIL_BYTES { + return text; + } + let mut cut = MAX_DETAIL_BYTES; + while !text.is_char_boundary(cut) { + cut -= 1; + } + &text[..cut] +} diff --git a/src/station_link/stream.rs b/src/station_link/stream.rs new file mode 100644 index 0000000..ec9ff6f --- /dev/null +++ b/src/station_link/stream.rs @@ -0,0 +1,760 @@ +//! Streaming RPC, as macula 12's link does it. Each session has a QUIC stream +//! of its own: the caller opens it with a signed STREAM_OPEN naming its mode, +//! the station opens one of its own to the provider and relays between them. +//! After the open, each side sends frames signed by its own key (the +//! provider's under MACULA-PQ-STREAM-V1, the caller's under +//! MACULA-PQ-CALLER-STREAM-V1), each numbered from 0 on its side and bound to +//! the open's request hash. +//! +//! A stream is released, both its QUIC directions finished, on every path: +//! when it ends normally, when either side aborts or refuses it, when its +//! inbox is over its bound, when its link ends, and when an open or an +//! accepted stream fails before a session exists. The bounds are macula +//! 12.3.0's: an open of at most 1 MiB, read within 10 seconds of a stream +//! being opened to the provider, and at most 16 MiB of a stream's frames +//! received and not yet read. + +use std::collections::VecDeque; +use std::future::Future; +use std::sync::{Arc, Mutex, MutexGuard, Weak}; +use std::time::Duration; + +use tokio::sync::{watch, Notify}; + +use crate::cbor::{self, Value}; +use crate::frame::{ + self, RequestSpec, StreamEncoding, StreamFields, StreamMode, StreamRole, StreamState, + VerifiedRequest, +}; + +use super::admission::{Admission, SessionPlace, Verdict}; +use super::framing::{read_frame, FrameWriter, MAX_FRAME_BYTES}; +use super::serve::{bounded_detail, BoxFuture, StreamOffer, CODE_REQUEST_COPY}; +use super::{frame_type_of, now_ms, Inner, Link, LinkError}; + +const STREAM_OPEN_BYTES: usize = 1024 * 1024; +const STREAM_OPEN_WAIT: Duration = Duration::from_secs(10); +const STREAM_INBOX: usize = 16 * 1024 * 1024; + +/// How far ahead a STREAM_OPEN's deadline lies when its [`StreamCall`] names +/// none, as macula's default. +pub const DEFAULT_STREAM_DEADLINE: Duration = Duration::from_secs(30); + +/// The refusal codes of a STREAM_OPEN, besides the admission's own, as +/// macula's refuse_open sends them, and the code of a failed handler. +const CODE_STREAM_NOT_FOUND: &str = "not_found"; +const CODE_MODE_MISMATCH: &str = "mode_mismatch"; +const CODE_TOO_MANY_SESSIONS: &str = "too_many_sessions"; +const CODE_STREAM_HANDLER_ERROR: &str = "error"; + +/// Serves one streaming session. When it returns `Ok` and has not ended the +/// stream, the stream is closed on both sides; an `Err` or a panic aborts it +/// with code `error` and the error's text, as macula aborts a stream whose +/// handler failed. A handler still running when its stream ends is dropped. +pub type StreamHandler = Arc BoxFuture> + Send + Sync>; + +/// A [`StreamHandler`] from an async closure. +pub fn stream_handler(f: F) -> StreamHandler +where + F: Fn(Stream) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, +{ + Arc::new(move |s| Box::pin(f(s))) +} + +/// A streaming session to open: the realm and procedure, the provider it +/// targets, the mode, the open's payload, how far ahead its deadline lies +/// ([`DEFAULT_STREAM_DEADLINE`] when zero), and a UCAN and its proofs for a +/// gated procedure. Its default mode is server_stream. +#[derive(Debug, Clone, PartialEq)] +pub struct StreamCall { + pub realm: [u8; 32], + pub procedure: String, + pub target: [u8; 32], + pub mode: StreamMode, + pub payload: Value, + pub deadline: Duration, + pub token: Option>, + pub proofs: Vec>, +} + +impl Default for StreamCall { + fn default() -> Self { + StreamCall { + realm: [0; 32], + procedure: String::new(), + target: [0; 32], + mode: StreamMode::ServerStream, + payload: Value::Map(Vec::new()), + deadline: Duration::ZERO, + token: None, + proofs: Vec::new(), + } + } +} + +/// One frame the peer sent, verified: a chunk, the peer's end (role `Send` +/// ends its sending only, `Both` the stream), or the provider's terminal +/// value. +#[derive(Debug, Clone, PartialEq)] +pub enum StreamEvent { + Data { + encoding: StreamEncoding, + body: Value, + }, + End { + role: StreamRole, + }, + Reply { + payload: Value, + }, +} + +/// One streaming session, on either side. Cloning it shares the session. +#[derive(Clone)] +pub struct Stream { + inner: Arc, +} + +/// What a served stream's inbox holds is charged to its caller's budget in +/// the node's admission, and the session holds its place there. +struct Budget { + admission: Arc, + caller: [u8; 32], + place: Option, +} + +pub(super) struct StreamInner { + link: Arc, + writer: FrameWriter, + open: VerifiedRequest, + caller: bool, + /// Orders a frame's seq with its write. + send_seq: tokio::sync::Mutex, + state: Mutex, + budget: Mutex>, + notify: Notify, + done_tx: watch::Sender, +} + +#[derive(Default)] +struct StreamSide { + /// This side sent its last frame, or will send no more. + sent_end: bool, + /// The peer sent its last frame. + peer_ended: bool, + inbox: VecDeque<(StreamEvent, usize)>, + held: usize, + ended: bool, + err: Option, +} + +impl Stream { + /// The stream's verified STREAM_OPEN: its caller, procedure, mode and + /// payload. + pub fn request(&self) -> &VerifiedRequest { + &self.inner.open + } + + /// Sends a raw chunk. + pub async fn send(&self, body: &[u8]) -> Result<(), LinkError> { + self.inner + .send( + |seq| StreamFields::Data { + seq, + encoding: StreamEncoding::Raw, + body: Value::Bytes(body.to_vec()), + }, + false, + ) + .await + } + + /// Sends a structured chunk. + pub async fn send_value(&self, v: Value) -> Result<(), LinkError> { + self.inner + .send( + |seq| StreamFields::Data { + seq, + encoding: StreamEncoding::Msgpack, + body: v.clone(), + }, + false, + ) + .await + } + + /// Ends this side's sending; the peer may still send. + pub async fn close_send(&self) -> Result<(), LinkError> { + self.inner + .send( + |seq| StreamFields::End { + seq, + role: StreamRole::Send, + }, + true, + ) + .await + } + + /// Ends the stream on both sides. + pub async fn close(&self) -> Result<(), LinkError> { + let sent = self + .inner + .send( + |seq| StreamFields::End { + seq, + role: StreamRole::Both, + }, + true, + ) + .await; + StreamInner::end(&self.inner, None); + sent + } + + /// Sends the provider's terminal value and ends the stream. + pub async fn reply(&self, payload: Value) -> Result<(), LinkError> { + let sent = self + .inner + .send( + |seq| StreamFields::Reply { + seq, + payload: payload.clone(), + }, + true, + ) + .await; + StreamInner::end(&self.inner, None); + sent + } + + /// Ends the stream with a STREAM_ERROR of `code` and `message`. + pub async fn abort(&self, code: &str, message: &str) -> Result<(), LinkError> { + self.inner.abort(code, message).await + } + + /// The next frame the peer sent. After the stream ends, once every event + /// before it is read, it returns [`LinkError::EndOfStream`] for a normal + /// end and the error that ended it otherwise. + pub async fn recv(&self) -> Result { + loop { + let notified = self.inner.notify.notified(); + { + let mut side = self.inner.side(); + if let Some((event, size)) = side.inbox.pop_front() { + side.held -= size; + drop(side); + self.inner.release_inbox(size); + return Ok(event); + } + if side.ended { + return Err(side.err.clone().unwrap_or(LinkError::EndOfStream)); + } + } + notified.await; + } + } + + /// Waits until the stream has ended and been released, and says why: + /// `None` for a normal end. + pub async fn done(&self) -> Option { + let mut done = self.inner.done_tx.subscribe(); + let _ = done.wait_for(|ended| *ended).await; + self.inner.side().err.clone() + } +} + +impl StreamInner { + fn new( + link: Arc, + send: quinn::SendStream, + open: VerifiedRequest, + caller: bool, + ) -> Arc { + Arc::new(StreamInner { + link, + writer: FrameWriter::new(send), + open, + caller, + send_seq: tokio::sync::Mutex::new(0), + state: Mutex::new(StreamSide::default()), + budget: Mutex::new(None), + notify: Notify::new(), + done_tx: watch::channel(false).0, + }) + } + + fn side(&self) -> MutexGuard<'_, StreamSide> { + self.state.lock().unwrap_or_else(|p| p.into_inner()) + } + + fn budget(&self) -> MutexGuard<'_, Option> { + self.budget.lock().unwrap_or_else(|p| p.into_inner()) + } + + /// Signs the fields `at(seq)` builds at this side's next seq and writes + /// them; `last` marks this side's last frame, after which its QUIC + /// direction is finished. + async fn send( + self: &Arc, + at: impl FnOnce(u64) -> StreamFields, + last: bool, + ) -> Result<(), LinkError> { + let mut seq = self.send_seq.lock().await; + if self.side().sent_end { + return Err(LinkError::StreamClosed); + } + let fields = at(*seq); + let signed = if self.caller { + frame::sign_caller_stream(&fields, &self.open, &self.link.key)? + } else { + frame::sign_provider_stream(&fields, &self.open, &self.link.key)? + }; + let encoded = cbor::encode(&signed) + .map_err(|e| LinkError::Frame(frame::FrameError::Payload(e.to_string())))?; + if let Err(e) = self.writer.write(&encoded, MAX_FRAME_BYTES).await { + self.side().sent_end = true; + return Err(e); + } + *seq += 1; + if last { + let peer_ended = { + let mut side = self.side(); + side.sent_end = true; + side.peer_ended + }; + self.writer.finish().await; + if peer_ended { + StreamInner::end(self, None); + } + } + Ok(()) + } + + async fn abort(self: &Arc, code: &str, message: &str) -> Result<(), LinkError> { + let sent = self + .send( + |seq| StreamFields::Error { + seq, + code: code.to_string(), + message: message.to_string(), + }, + true, + ) + .await; + StreamInner::end( + self, + Some(LinkError::Stream { + code: code.to_string(), + message: message.to_string(), + relay: false, + }), + ); + sent + } + + /// Queues `event` for recv, refusing it when it would take the inbox, or + /// the node's budget for served streams, past its bound. + fn deliver(&self, event: StreamEvent, size: usize) -> bool { + { + let mut side = self.side(); + if side.held + size > STREAM_INBOX { + return false; + } + if let Some(budget) = &*self.budget() { + if !budget.admission.charge_inbox(budget.caller, size) { + return false; + } + } + side.inbox.push_back((event, size)); + side.held += size; + } + self.notify.notify_one(); + true + } + + fn release_inbox(&self, size: usize) { + if let Some(budget) = &*self.budget() { + budget.admission.release_inbox(budget.caller, size); + } + } + + /// Ends the stream after the peer's last frame: this side sends no more, + /// and `err` is why it ended, `None` for a normal end. + fn peer_finished(self: &Arc, err: Option) { + self.side().peer_ended = true; + StreamInner::end(self, err); + } + + /// Aborts the stream from this side for a fault it found in what the + /// peer sent, or an inbox over its bound, telling the peer when it still + /// can. + async fn fail(self: &Arc, code: &str, cause: Option) { + let message = cause + .as_deref() + .map(bounded_detail) + .unwrap_or("") + .to_string(); + let _ = self.abort(code, &message).await; + } + + /// Releases the stream once: its sending side finished after this side's + /// last frame and reset otherwise, its reader stopped, what its inbox + /// held and its session's place given back. + pub(super) fn end(this: &Arc, err: Option) { + let graceful = { + let mut side = this.side(); + if side.ended { + return; + } + side.ended = true; + if side.err.is_none() { + side.err = err; + } + let graceful = side.sent_end; + side.sent_end = true; + graceful + }; + if !graceful { + let released = this.clone(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { released.writer.reset().await }); + } + } + if let Some(budget) = this.budget().take() { + let held = std::mem::take(&mut this.side().held); + budget.admission.release_inbox(budget.caller, held); + drop(budget.place); + } + this.link + .lock() + .streams + .retain(|w| w.strong_count() > 0 && !std::ptr::eq(w.as_ptr(), Arc::as_ptr(this))); + let _ = this.done_tx.send(true); + this.notify.notify_waiters(); + this.notify.notify_one(); + } +} + +/// Keeps `s` among the link's streams, ended with the link; false once the +/// link has ended. +fn hold_stream(inner: &Inner, s: &Arc) -> bool { + let mut state = inner.lock(); + if state.ended.is_some() { + return false; + } + state.streams.push(Arc::downgrade(s)); + true +} + +/// Releases a QUIC stream no session holds, in both directions. +fn abandon(mut send: quinn::SendStream, mut recv: quinn::RecvStream) { + let _ = send.reset(0u32.into()); + let _ = recv.stop(0u32.into()); +} + +impl Link { + /// Opens a streaming session: a QUIC stream of its own, on which it + /// writes the signed STREAM_OPEN. A stream it opens but cannot write the + /// open on is released before the error returns. + pub async fn open_stream(&self, c: StreamCall) -> Result { + let inner = &self.inner; + let deadline = if c.deadline.is_zero() { + DEFAULT_STREAM_DEADLINE + } else { + c.deadline + }; + let mut request_id = [0u8; 16]; + aws_lc_rs::rand::fill(&mut request_id) + .map_err(|_| LinkError::Io("no randomness".into()))?; + let signed = frame::sign_stream_open( + &RequestSpec { + request_id, + realm: c.realm, + procedure: c.procedure, + target: c.target, + deadline: (now_ms() + deadline.as_millis() as i64) as u64, + payload: c.payload, + mode: Some(c.mode), + token: c.token, + proofs: c.proofs, + source_route: None, + retry_budget: None, + }, + &inner.key, + )?; + let encoded = cbor::encode(&signed) + .map_err(|e| LinkError::Frame(frame::FrameError::Payload(e.to_string())))?; + if encoded.len() > STREAM_OPEN_BYTES { + return Err(LinkError::StreamOpenTooLarge(encoded.len())); + } + let open = frame::verify_request(&signed, inner.profile)?; + let state = frame::open_stream(&open)?; + let (send, recv) = inner + .connection + .open_bi() + .await + .map_err(|e| LinkError::Io(format!("open a stream: {e}")))?; + let s = StreamInner::new(inner.clone(), send, open, true); + let held = hold_stream(inner, &s); + let written = match held { + true => s.writer.write(&encoded, STREAM_OPEN_BYTES).await, + false => Err(inner.lock().ended.clone().unwrap_or(LinkError::Closed)), + }; + if let Err(e) = written { + StreamInner::end(&s, Some(e.clone())); + let mut recv = recv; + let _ = recv.stop(0u32.into()); + return Err(e); + } + tokio::spawn(read(s.clone(), recv, state)); + Ok(Stream { inner: s }) + } +} + +/// Takes each stream the station opens to this link, until the link ends. +pub(super) async fn accept_streams(link: Weak) { + let Some(connection) = link.upgrade().map(|l| l.connection.clone()) else { + return; + }; + while let Ok((send, recv)) = connection.accept_bi().await { + tokio::spawn(incoming(link.clone(), send, recv)); + } +} + +/// Reads a stream's first frame within 10 seconds and starts the session it +/// opens, or refuses it. A stream that fails before a session exists is +/// released: one that does not deliver a STREAM_OPEN in time, whose first +/// frame is not one, that does not verify or targets another node is dropped +/// without a word, and one the provider refuses is told why at seq 0. +async fn incoming(link: Weak, send: quinn::SendStream, mut recv: quinn::RecvStream) { + let Some(inner) = link.upgrade() else { return }; + let payload = match tokio::time::timeout( + STREAM_OPEN_WAIT, + read_frame(&mut recv, STREAM_OPEN_BYTES), + ) + .await + { + Ok(Ok(payload)) => payload, + _ => { + inner.count("stream_open_unread"); + abandon(send, recv); + return; + } + }; + let v = match cbor::decode(&payload) { + Ok(v) if frame_type_of(&v) == "stream_open" => v, + _ => { + inner.count("stream_open_malformed"); + abandon(send, recv); + return; + } + }; + let Ok(open) = frame::verify_request(&v, inner.profile) else { + inner.count("stream_open_unverified"); + abandon(send, recv); + return; + }; + if open.target != inner.self_id { + inner.count("stream_for_another_node"); + abandon(send, recv); + return; + } + let Ok(state) = frame::open_stream(&open) else { + abandon(send, recv); + return; + }; + let s = StreamInner::new(inner.clone(), send, open.clone(), false); + let offer = match admit_stream(&inner, &open) { + Ok(offer) => offer, + Err(code) => return refuse(&s, code, recv).await, + }; + let Some(place) = inner.admission.open_session(open.caller) else { + return refuse(&s, CODE_TOO_MANY_SESSIONS, recv).await; + }; + *s.budget() = Some(Budget { + admission: inner.admission.clone(), + caller: open.caller, + place: Some(place), + }); + if !hold_stream(&inner, &s) { + StreamInner::end(&s, Some(LinkError::Closed)); + let _ = recv.stop(0u32.into()); + return; + } + tokio::spawn(read(s.clone(), recv, state)); + tokio::spawn(serve(s, offer)); +} + +/// Judges an open as macula's link does, in its order: the admission (one +/// run per request, the deadline window, its bounds), the procedure served +/// here as a stream, and its mode. The offer, or the code to refuse with. +fn admit_stream(inner: &Inner, open: &VerifiedRequest) -> Result { + match inner.admission.admit(open, &inner.share, now_ms()) { + Verdict::Refused(code) => return Err(code), + Verdict::Copy(_) => return Err(CODE_REQUEST_COPY), + Verdict::New => {} + } + let state = inner.lock(); + let offer = state + .served + .get(&(open.realm, open.procedure.clone())) + .and_then(|s| s.offer.stream.clone()) + .ok_or(CODE_STREAM_NOT_FOUND)?; + if Some(offer.mode) != open.mode { + return Err(CODE_MODE_MISMATCH); + } + Ok(offer) +} + +/// Answers an open with a STREAM_ERROR of `code` at seq 0 and releases the +/// stream. +async fn refuse(s: &Arc, code: &str, mut recv: quinn::RecvStream) { + s.link.count(&format!("stream_refused_{code}")); + let _ = s.abort(code, "").await; + let _ = recv.stop(0u32.into()); +} + +/// Runs the handler for the session, and ends the stream as the handler +/// leaves it: closed when it returns `Ok` without ending it, aborted with its +/// error or panic. A handler still running when the stream ends is dropped. +async fn serve(s: Arc, offer: StreamOffer) { + let stream = Stream { inner: s.clone() }; + let mut running = tokio::spawn((offer.handler)(stream.clone())); + let mut done = s.done_tx.subscribe(); + let outcome = tokio::select! { + outcome = &mut running => outcome, + _ = done.wait_for(|ended| *ended) => { + running.abort(); + return; + } + }; + match outcome { + Ok(Ok(())) => { + let _ = stream.close().await; + } + Ok(Err(e)) => { + let _ = stream + .abort(CODE_STREAM_HANDLER_ERROR, bounded_detail(&e)) + .await; + } + Err(panicked) => { + let _ = stream + .abort( + CODE_STREAM_HANDLER_ERROR, + bounded_detail(&panicked.to_string()), + ) + .await; + } + } +} + +/// Verifies the peer's frames until the stream ends. It is the stream's one +/// reader, the only holder of its verifier state; when it returns, its +/// receiving side is dropped, which stops it. +async fn read(s: Arc, mut recv: quinn::RecvStream, mut state: StreamState) { + let mut done = s.done_tx.subscribe(); + loop { + let payload = tokio::select! { + _ = done.wait_for(|ended| *ended) => return, + payload = read_frame(&mut recv, MAX_FRAME_BYTES) => payload, + }; + let payload = match payload { + Ok(payload) => payload, + Err(e) => return read_ended(&s, e), + }; + match received(&s, &payload, &state).await { + Some(next) => state = next, + None => return, + } + } +} + +/// Ends a stream whose peer direction finished: after the peer's last frame +/// that is expected, and before it the stream was lost. +fn read_ended(s: &Arc, e: LinkError) { + if s.side().peer_ended { + return; + } + let err = s.link.lock().ended.clone().unwrap_or(e); + StreamInner::end(s, Some(err)); +} + +/// Handles one frame from the peer; the next verifier state, or `None` when +/// reading stops. +async fn received( + s: &Arc, + payload: &[u8], + state: &StreamState, +) -> Option { + let v = match cbor::decode(payload) { + Ok(v) => v, + Err(e) => { + s.fail("malformed_frame", Some(e.to_string())).await; + return None; + } + }; + if s.caller && v.get("relay_error").is_some() { + match frame::verify_relay_error(&v, &s.open, s.link.profile, &s.link.station.node_id) { + Ok(relayed) => s.peer_finished(Some(LinkError::Stream { + code: relayed.code, + message: String::new(), + relay: true, + })), + Err(e) => s.fail("malformed_frame", Some(e.to_string())).await, + } + return None; + } + let verified = if s.caller { + frame::verify_provider_stream(&v, state, s.link.profile) + } else { + frame::verify_caller_stream(&v, state, s.link.profile) + }; + let (verified, next) = match verified { + Ok(verified) => verified, + Err(e) => { + s.fail("malformed_frame", Some(e.to_string())).await; + return None; + } + }; + let size = payload.len(); + match verified.fields { + StreamFields::Error { code, message, .. } => { + s.peer_finished(Some(LinkError::Stream { + code, + message, + relay: false, + })); + None + } + StreamFields::Reply { payload, .. } => { + s.deliver(StreamEvent::Reply { payload }, size); + s.peer_finished(None); + None + } + StreamFields::End { role, .. } => { + s.deliver(StreamEvent::End { role }, size); + if role == StreamRole::Both { + s.peer_finished(None); + return None; + } + let mine = { + let mut side = s.side(); + side.peer_ended = true; + side.sent_end + }; + if mine { + StreamInner::end(s, None); + } + None + } + StreamFields::Data { encoding, body, .. } => { + if !s.deliver(StreamEvent::Data { encoding, body }, size) { + s.fail("resource_exhausted", None).await; + return None; + } + Some(next) + } + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..ad9fad8 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,130 @@ +//! Two in-process macula 12 stations sharing a DHT and a test realm with one +//! org, for a test file: macula-go's teststation, built to target/teststation +//! by scripts/build-teststation.sh (or named by MACULA_TESTSTATION), driven +//! over its stdin. + +#![allow(dead_code)] + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::Mutex; + +use macula_rust::profile::Profile; +use macula_rust::transport::Target; + +/// One station: where it listens and the node_id it proves. +#[derive(Debug, Clone)] +pub struct TestStation { + pub host: String, + pub port: u16, + pub node_id: [u8; 32], +} + +/// The running stations, their realm and org, and the helper's stdin. +pub struct TestStations { + pub profile: Profile, + pub stations: Vec, + pub realm_id: [u8; 32], + pub realm_key: Vec, + pub org: String, + child: Child, + io: Mutex<(ChildStdin, BufReader)>, +} + +impl TestStations { + /// Starts the stations in `profile`. A missing helper fails the test, + /// naming how to build it: a test that cannot reach its stations proves + /// nothing, so it is never skipped. + pub fn start(profile: Profile) -> TestStations { + let binary = std::env::var("MACULA_TESTSTATION").unwrap_or_else(|_| { + concat!(env!("CARGO_MANIFEST_DIR"), "/target/teststation").to_string() + }); + assert!( + std::path::Path::new(&binary).exists(), + "{binary} is missing: run scripts/build-teststation.sh first" + ); + let mut child = Command::new(&binary) + .arg(profile.name()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("the teststation starts"); + let stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut line = String::new(); + stdout + .read_line(&mut line) + .expect("the teststation prints its stations"); + let info: serde_json::Value = + serde_json::from_str(&line).expect("the teststation's JSON line"); + let id = |v: &serde_json::Value| -> [u8; 32] { + hex::decode(v.as_str().unwrap()) + .unwrap() + .try_into() + .unwrap() + }; + let stations = info["stations"] + .as_array() + .unwrap() + .iter() + .map(|s| TestStation { + host: s["host"].as_str().unwrap().to_string(), + port: s["port"].as_u64().unwrap() as u16, + node_id: id(&s["node_id"]), + }) + .collect(); + TestStations { + profile, + stations, + realm_id: id(&info["realm_id"]), + realm_key: hex::decode(info["realm_key"].as_str().unwrap()).unwrap(), + org: info["org"].as_str().unwrap().to_string(), + child, + io: Mutex::new((stdin, stdout)), + } + } + + /// Station `i` as a dial target, pinned by its node_id. + pub fn target(&self, i: usize) -> Target { + let s = &self.stations[i]; + Target { + host: s.host.clone(), + port: s.port, + profile: self.profile, + expected_node_id: s.node_id, + } + } + + /// The org delegates its procedures to `node_id`. + pub fn admit(&self, node_id: &[u8; 32]) { + let reply = self.ask(&format!("admit {}", hex::encode(node_id))); + assert!(reply.starts_with("admitted"), "{reply}"); + } + + /// How many streams the stations relay now. + pub fn relayed(&self) -> u64 { + let reply = self.ask("relayed"); + reply + .split_whitespace() + .nth(1) + .and_then(|n| n.parse().ok()) + .expect("relayed ") + } + + fn ask(&self, command: &str) -> String { + let mut io = self.io.lock().unwrap(); + writeln!(io.0, "{command}").unwrap(); + io.0.flush().unwrap(); + let mut line = String::new(); + io.1.read_line(&mut line).unwrap(); + line.trim_end().to_string() + } +} + +impl Drop for TestStations { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} diff --git a/tests/statement_issuer.rs b/tests/statement_issuer.rs new file mode 100644 index 0000000..1f6e216 --- /dev/null +++ b/tests/statement_issuer.rs @@ -0,0 +1,107 @@ +//! The client's statement issuer (D22): a CONNECT key bound to the identity +//! key, a status statement for it reissued every 15 minutes and handed to its +//! subscribers, the CONNECT key rotated every 5 days, and material for a new +//! dial that is always in force. + +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::Arc; + +use macula_rust::binding::{verify_connect_binding, verify_status}; +use macula_rust::node_key::{NodeKey, Purpose}; +use macula_rust::profile::Profile; +use macula_rust::statement_issuer::{ + IssuerError, StatementIssuer, CONNECT_ROTATE_EVERY_MS, STATEMENT_EVERY_MS, +}; + +const P: Profile = Profile::PqPure; +const START: i64 = 1_789_000_000_000; + +fn issuer() -> (Arc, StatementIssuer, Vec) { + let clock = Arc::new(AtomicI64::new(START)); + let seen = clock.clone(); + let identity = Arc::new(NodeKey::generate(Purpose::Identity, P).unwrap()); + let carried = identity.public_key(); + let issuer = + StatementIssuer::new(identity, Box::new(move || seen.load(Ordering::SeqCst))).unwrap(); + (clock, issuer, carried) +} + +#[test] +fn connect_material_is_a_bound_connect_key_with_a_statement_in_force() { + let (_clock, issuer, identity) = issuer(); + let material = issuer.connect_material().unwrap(); + assert_eq!(material.key.purpose(), Purpose::Connect); + verify_connect_binding( + &material.binding, + &identity, + P, + &material.key.public_key(), + START, + ) + .unwrap(); + assert_eq!( + verify_status(&material.status, &material.binding, &identity, P, START).unwrap(), + START + 60 * 60 * 1000 + ); +} + +#[test] +fn each_tick_hands_subscribers_the_newest_statement() { + let (clock, issuer, identity) = issuer(); + let material = issuer.connect_material().unwrap(); + let mut statements = issuer.subscribe(&material.binding).unwrap(); + clock.store(START + STATEMENT_EVERY_MS, Ordering::SeqCst); + issuer.tick().unwrap(); + clock.store(START + 2 * STATEMENT_EVERY_MS, Ordering::SeqCst); + issuer.tick().unwrap(); + // The subscription holds the newest statement only. + let newest = statements.try_recv().expect("a statement was handed over"); + assert!(statements.try_recv().is_err()); + let expires = verify_status( + &newest, + &material.binding, + &identity, + P, + START + 2 * STATEMENT_EVERY_MS, + ) + .unwrap(); + assert_eq!(expires, START + 2 * STATEMENT_EVERY_MS + 60 * 60 * 1000); +} + +#[test] +fn the_connect_key_rotates_and_a_late_dial_still_gets_material_in_force() { + let (clock, issuer, identity) = issuer(); + let first = issuer.connect_material().unwrap(); + // No tick for 5 days: the next dial's material is rotated and in force. + let later = START + CONNECT_ROTATE_EVERY_MS; + clock.store(later, Ordering::SeqCst); + let second = issuer.connect_material().unwrap(); + assert_ne!(second.key.public_key(), first.key.public_key()); + verify_connect_binding( + &second.binding, + &identity, + P, + &second.key.public_key(), + later, + ) + .unwrap(); + verify_status(&second.status, &second.binding, &identity, P, later).unwrap(); + assert_eq!(issuer.rotation_failures(), 0); +} + +#[test] +fn a_binding_the_issuer_does_not_hold_cannot_be_subscribed_to() { + let (_clock, mine, _) = issuer(); + let (_clock2, other, _) = issuer(); + let foreign = other.connect_material().unwrap(); + assert!(matches!( + mine.subscribe(&foreign.binding), + Err(IssuerError::UnknownBinding) + )); +} + +#[test] +fn only_an_identity_key_issues() { + let connect = NodeKey::generate(Purpose::Connect, P).unwrap(); + assert!(StatementIssuer::new(Arc::new(connect), Box::new(|| START)).is_err()); +} diff --git a/tests/station_link.rs b/tests/station_link.rs new file mode 100644 index 0000000..d78b5c5 --- /dev/null +++ b/tests/station_link.rs @@ -0,0 +1,357 @@ +//! A link to one macula 12 station, against macula-go's in-process stations +//! (tests/common, the Go teststation): the v4 handshake over QUIC, DHT calls, +//! pubsub, serving under an org and in a node's own namespace, and streams, +//! each stream released. + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use common::TestStations; +use macula_rust::cbor::Value; +use macula_rust::frame::{StreamEncoding, StreamMode, StreamRole}; +use macula_rust::handshake::HandshakeError; +use macula_rust::node_key::{NodeKey, PUZZLE_DIFFICULTY}; +use macula_rust::profile::Profile; +use macula_rust::record::{self, new_node_record, NodeRecordOptions, RecordType}; +use macula_rust::statement_issuer::StatementIssuer; +use macula_rust::station_link::{ + handler, stream_handler, Call, Config, Link, LinkError, Offer, Publication, StreamCall, + StreamEvent, +}; + +/// A link as a new node: its own identity key and issuer. +async fn node(env: &TestStations, station: usize) -> Link { + let identity = NodeKey::generate_identity(env.profile, PUZZLE_DIFFICULTY).unwrap(); + link_as(env, station, identity).await.unwrap() +} + +async fn link_as(env: &TestStations, station: usize, identity: NodeKey) -> Result { + let identity = Arc::new(identity); + let issuer = StatementIssuer::with_wall_clock(identity.clone()).unwrap(); + Link::dial(Config::new(env.target(station), identity, issuer)).await +} + +async fn eventually(what: &str, mut ok: impl FnMut() -> bool) { + for _ in 0..400 { + if ok() { + return; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + panic!("never: {what}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_node_links_to_a_station_it_pinned_in_either_profile() { + for profile in [Profile::PqPure, Profile::PqHybrid] { + let env = TestStations::start(profile); + let link = node(&env, 0).await; + assert_eq!(link.station_node_id(), env.stations[0].node_id); + link.close("done").await.unwrap(); + assert!(matches!(link.error(), Some(LinkError::Closed))); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_station_that_is_not_the_node_pinned_is_refused() { + let env = TestStations::start(Profile::PqPure); + let mut target = env.target(0); + target.expected_node_id = env.stations[1].node_id; + let identity = Arc::new(NodeKey::generate_identity(env.profile, PUZZLE_DIFFICULTY).unwrap()); + let issuer = StatementIssuer::with_wall_clock(identity.clone()).unwrap(); + let refused = Link::dial(Config::new(target, identity, issuer)).await; + assert!(matches!( + refused, + Err(LinkError::Handshake( + HandshakeError::PeerIdentityMismatch { .. } + )) + )); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_dht_holds_verified_records() { + let env = TestStations::start(Profile::PqPure); + let identity = NodeKey::generate_identity(env.profile, PUZZLE_DIFFICULTY).unwrap(); + let node_id = identity.node_id().unwrap(); + let signed = record::sign( + &new_node_record(&node_id, &[], 0, &NodeRecordOptions::default()).unwrap(), + &identity, + ) + .unwrap(); + let link = link_as(&env, 0, identity).await.unwrap(); + + let (endpoints, dropped) = link + .find_records_by_type(RecordType::STATION_ENDPOINT) + .await + .unwrap(); + assert_eq!(dropped, 0); + let mut signers: Vec<[u8; 32]> = endpoints + .iter() + .map(|r| r.record().signed.as_ref().unwrap().key_id) + .collect(); + signers.sort(); + let mut stations: Vec<[u8; 32]> = env.stations.iter().map(|s| s.node_id).collect(); + stations.sort(); + assert_eq!(signers, stations); + + assert!(matches!( + link.find_record(&[0x22; 32]).await, + Err(LinkError::RecordNotFound) + )); + link.put_record(&record::encode(&signed).unwrap()) + .await + .unwrap(); + let found = link.find_record(&node_id).await.unwrap(); + assert_eq!(found.record().version, signed.version); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_call_nobody_serves_is_answered_with_the_station_s_relay_error() { + let env = TestStations::start(Profile::PqPure); + let link = node(&env, 0).await; + let outcome = link + .call(Call { + realm: env.realm_id, + procedure: format!("{}/nothing", env.org), + target: [9; 32], + payload: Value::Null, + ..Call::default() + }) + .await; + match outcome { + Err(LinkError::Relay { code, reported_by }) => { + assert_eq!(code, "unknown_next_peer"); + assert_eq!(reported_by, env.stations[0].node_id); + } + other => panic!("{other:?}"), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_publication_is_heard_once_by_each_subscriber() { + let env = TestStations::start(Profile::PqHybrid); + let listener = node(&env, 0).await; + let publisher = node(&env, 0).await; + let topic = "mcl-rust/tests/greeting_sent_v1"; + let mut sub = listener.subscribe(&env.realm_id, topic).await.unwrap(); + tokio::time::sleep(Duration::from_millis(200)).await; + publisher + .publish(Publication { + realm: env.realm_id, + topic: topic.to_string(), + payload: Value::text("hi"), + ttl_ms: None, + }) + .await + .unwrap(); + let event = tokio::time::timeout(Duration::from_secs(5), sub.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(event.payload, Value::text("hi")); + assert_eq!(event.publisher, publisher.node_id()); + assert_eq!(event.topic, topic); + assert!(tokio::time::timeout(Duration::from_millis(300), sub.recv()) + .await + .is_err()); + sub.unsubscribe().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_procedure_in_a_node_s_own_namespace_is_served_and_called() { + let env = TestStations::start(Profile::PqPure); + let provider = node(&env, 0).await; + let caller = node(&env, 0).await; + let ring = record::own_procedure(&provider.node_id(), "ring"); + let served = provider + .serve(Offer::unary( + env.realm_id, + &ring, + handler(|r| async move { + if r.payload == Value::text("fail") { + return Err("refused by the handler".to_string()); + } + Ok(Value::Map(vec![( + Value::text("rung_by"), + Value::Bytes(r.caller.to_vec()), + )])) + }), + )) + .await + .unwrap(); + let call = |payload: Value| Call { + realm: env.realm_id, + procedure: ring.clone(), + target: provider.node_id(), + payload, + ..Call::default() + }; + let answered = caller.call(call(Value::Null)).await.unwrap(); + assert_eq!( + answered.get("rung_by"), + Some(&Value::Bytes(caller.node_id().to_vec())) + ); + match caller.call(call(Value::text("fail"))).await { + Err(LinkError::Provider { code, detail, .. }) => { + assert_eq!(code, "handler_error"); + assert_eq!(detail.as_deref(), Some("refused by the handler")); + } + other => panic!("{other:?}"), + } + // Withdrawn, the procedure is no longer routed to the provider. + served.stop().await.unwrap(); + assert!(matches!( + caller.call(call(Value::Null)).await, + Err(LinkError::Relay { .. }) + )); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_org_procedure_is_served_once_the_org_delegates_to_the_node() { + let env = TestStations::start(Profile::PqHybrid); + let identity = NodeKey::generate_identity(env.profile, PUZZLE_DIFFICULTY).unwrap(); + env.admit(&identity.node_id().unwrap()); + let provider = link_as(&env, 0, identity).await.unwrap(); + let caller = node(&env, 0).await; + let procedure = format!("{}/echo", env.org); + let mut offer = Offer::unary( + env.realm_id, + &procedure, + handler(|r| async move { Ok(r.payload) }), + ); + offer.realm_key = Some(env.realm_key.clone()); + let served = provider.serve(offer).await.unwrap(); + let answered = caller + .call(Call { + realm: env.realm_id, + procedure, + target: provider.node_id(), + payload: Value::text("hello"), + ..Call::default() + }) + .await + .unwrap(); + assert_eq!(answered, Value::text("hello")); + served.stop().await.unwrap(); + + // Without the realm key, an org procedure is not offered at all. + let bare = Offer::unary( + env.realm_id, + &format!("{}/other", env.org), + handler(|r| async move { Ok(r.payload) }), + ); + assert!(matches!( + provider.serve(bare).await, + Err(LinkError::InvalidOffer) + )); +} + +#[tokio::test(flavor = "multi_thread")] +async fn streams_deliver_their_frames_and_are_released() { + let env = TestStations::start(Profile::PqPure); + let provider = node(&env, 0).await; + let caller = node(&env, 0).await; + let watch = record::own_procedure(&provider.node_id(), "watch"); + let count = record::own_procedure(&provider.node_id(), "count"); + let _watch = provider + .serve(Offer::stream( + env.realm_id, + &watch, + StreamMode::ServerStream, + stream_handler(|stream| async move { + for chunk in ["one", "two", "three"] { + stream + .send(chunk.as_bytes()) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) + }), + )) + .await + .unwrap(); + let _count = provider + .serve(Offer::stream( + env.realm_id, + &count, + StreamMode::ClientStream, + stream_handler(|stream| async move { + let mut total = 0i128; + while let Ok(event) = stream.recv().await { + match event { + StreamEvent::Data { + body: Value::Bytes(b), + .. + } => total += b.len() as i128, + StreamEvent::End { .. } => break, + _ => {} + } + } + stream + .reply(Value::Int(total)) + .await + .map_err(|e| e.to_string()) + }), + )) + .await + .unwrap(); + + let open = |procedure: &str, mode| StreamCall { + realm: env.realm_id, + procedure: procedure.to_string(), + target: provider.node_id(), + mode, + payload: Value::Null, + ..StreamCall::default() + }; + let stream = caller + .open_stream(open(&watch, StreamMode::ServerStream)) + .await + .unwrap(); + let mut got = Vec::new(); + loop { + match stream.recv().await.unwrap() { + StreamEvent::Data { + body: Value::Bytes(b), + encoding: StreamEncoding::Raw, + } => got.push(b), + StreamEvent::End { role } => { + assert_eq!(role, StreamRole::Both); + break; + } + other => panic!("{other:?}"), + } + } + assert_eq!( + got, + vec![b"one".to_vec(), b"two".to_vec(), b"three".to_vec()] + ); + + let upload = caller + .open_stream(open(&count, StreamMode::ClientStream)) + .await + .unwrap(); + for chunk in ["ab", "cde", "f"] { + upload.send(chunk.as_bytes()).await.unwrap(); + } + upload.close_send().await.unwrap(); + assert_eq!( + upload.recv().await.unwrap(), + StreamEvent::Reply { + payload: Value::Int(6) + } + ); + + // A mode the provider does not serve is refused. + let wrong = caller + .open_stream(open(&watch, StreamMode::Bidi)) + .await + .unwrap(); + match wrong.recv().await { + Err(LinkError::Stream { code, .. }) => assert_eq!(code, "mode_mismatch"), + other => panic!("{other:?}"), + } + eventually("every stream released", || env.relayed() == 0).await; +} diff --git a/tests/teststation/go.mod b/tests/teststation/go.mod new file mode 100644 index 0000000..2f28aa6 --- /dev/null +++ b/tests/teststation/go.mod @@ -0,0 +1,13 @@ +module github.com/macula-io/macula-rust/tests/teststation + +go 1.27.0 + +require github.com/macula-io/macula-go v0.12.0 + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/quic-go/quic-go v0.62.0 // indirect + golang.org/x/crypto v0.56.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect +) diff --git a/tests/teststation/go.sum b/tests/teststation/go.sum new file mode 100644 index 0000000..6371f9d --- /dev/null +++ b/tests/teststation/go.sum @@ -0,0 +1,20 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/macula-io/macula-go v0.12.0 h1:zn3ppxBZSb1QZy+Qsfstwq2IPWHVNLg6wtEYhOMk3+Y= +github.com/macula-io/macula-go v0.12.0/go.mod h1:mRMXty8IIDeuUgaaU8bAVWde6g94REj7GMeDBVr3mMM= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8= +github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/tests/teststation/main.go b/tests/teststation/main.go new file mode 100644 index 0000000..e86fdca --- /dev/null +++ b/tests/teststation/main.go @@ -0,0 +1,94 @@ +// Command teststation runs in-process macula 12 stations for the PHP +// tests: two stations sharing one DHT, and a test realm with one org. It +// prints one JSON line, {stations: [{host, port, node_id}], realm_id, +// realm_key, org}, then reads commands on stdin until it closes: +// +// admit the org delegates its procedures to that node +// relayed how many streams the stations relay now +// +// answering each with one line. It exits when stdin closes. +package main + +import ( + "bufio" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/macula-io/macula-go/profile" + "github.com/macula-io/macula-go/teststation" +) + +// helperT is teststation.T for a process rather than a test: failures go to +// stderr, a fatal one ends the process, and cleanups run at exit. +type helperT struct{ cleanups []func() } + +func (t *helperT) Helper() {} +func (t *helperT) Errorf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "teststation: "+format+"\n", args...) +} +func (t *helperT) Fatalf(format string, args ...any) { + t.Errorf(format, args...) + t.run() + os.Exit(1) +} +func (t *helperT) Cleanup(f func()) { t.cleanups = append(t.cleanups, f) } +func (t *helperT) run() { + for i := len(t.cleanups) - 1; i >= 0; i-- { + t.cleanups[i]() + } +} + +func main() { + t := &helperT{} + defer t.run() + p := profile.PQPure + if len(os.Args) > 1 { + parsed, err := profile.Parse(os.Args[1]) + if err != nil { + t.Fatalf("%v", err) + } + p = parsed + } + stations := []*teststation.Station{teststation.Start(t, p, "rust a"), teststation.Start(t, p, "rust b")} + teststation.ShareDHT(stations...) + realm := teststation.NewRealm(t, p, "macula-rust tests", "mcl-rust") + type station struct { + Host string `json:"host"` + Port uint16 `json:"port"` + NodeID string `json:"node_id"` + } + out := struct { + Stations []station `json:"stations"` + RealmID string `json:"realm_id"` + RealmKey string `json:"realm_key"` + Org string `json:"org"` + }{RealmID: hex.EncodeToString(realm.ID[:]), RealmKey: hex.EncodeToString(realm.RealmKey()), Org: realm.Org} + for _, s := range stations { + out.Stations = append(out.Stations, station{Host: s.Host, Port: s.Port, NodeID: hex.EncodeToString(s.NodeID[:])}) + } + line, _ := json.Marshal(out) + fmt.Println(string(line)) + in := bufio.NewScanner(os.Stdin) + for in.Scan() { + fields := strings.Fields(in.Text()) + switch { + case len(fields) == 2 && fields[0] == "admit": + raw, err := hex.DecodeString(fields[1]) + if err != nil || len(raw) != 32 { + fmt.Println("error bad node_id") + continue + } + var node [32]byte + copy(node[:], raw) + realm.Admit(t, stations[0], node) + fmt.Println("admitted", fields[1]) + case len(fields) == 1 && fields[0] == "relayed": + fmt.Println("relayed", stations[0].Relayed()+stations[1].Relayed()) + default: + fmt.Println("error unknown command") + } + } +} From a9ad0f30e15db6368dd37c35ddb6cc2bf67bc092 Mon Sep 17 00:00:00 2001 From: beamologist Date: Sat, 26 Sep 2026 09:36:54 +0200 Subject: [PATCH 09/12] pool: a node's set of station links, ported from macula-go v0.12.0 One link per pinned seed, all sharing the node's identity key, statement issuer, request admission, publication seq and event dedup; a link that ends is dialed again after the respawn delay and given back the node's subscriptions and served procedures. Calls and streams reach a provider at its own station: advertisements resolved from the DHT and trusted only under the pinned realm key (or signed by the namespace's own node), the serving station dialed from the endpoint record it signed itself, candidates tried freshest first. Publications are signed once and sent on the first replication_factor links. Records go through the first link that can carry them. Fixed: a done or stopped flag sent on a tokio watch channel with no receiver yet was lost (watch::Sender::send does not store it then), so Pool::close could wait forever on a member that had already stopped. Every such flag now uses send_replace. Tests: tests/pool.rs against the teststation's new lab mode (stations and realms started and shaped one by one), including a 2 s release deadline for relayed streams (#3). Co-Authored-By: Claude Opus 5.5 --- src/lib.rs | 1 + src/pool.rs | 482 +++++++++++++++++++++++++++++++++ src/pool/call.rs | 506 ++++++++++++++++++++++++++++++++++ src/pool/member.rs | 173 ++++++++++++ src/pool/pubsub.rs | 245 +++++++++++++++++ src/pool/serve.rs | 244 +++++++++++++++++ src/station_link.rs | 11 +- src/station_link/serve.rs | 5 +- src/station_link/stream.rs | 2 +- tests/common/lab.rs | 197 ++++++++++++++ tests/common/mod.rs | 67 +++-- tests/pool.rs | 538 +++++++++++++++++++++++++++++++++++++ tests/teststation/lab.go | 251 +++++++++++++++++ tests/teststation/main.go | 18 +- 14 files changed, 2706 insertions(+), 34 deletions(-) create mode 100644 src/pool.rs create mode 100644 src/pool/call.rs create mode 100644 src/pool/member.rs create mode 100644 src/pool/pubsub.rs create mode 100644 src/pool/serve.rs create mode 100644 tests/common/lab.rs create mode 100644 tests/pool.rs create mode 100644 tests/teststation/lab.go diff --git a/src/lib.rs b/src/lib.rs index 09fb1f7..4b458fc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod handshake; pub mod keystore; pub mod node_key; pub mod petname; +pub mod pool; pub mod profile; pub mod record; pub mod signed_object; diff --git a/src/pool.rs b/src/pool.rs new file mode 100644 index 0000000..f39f524 --- /dev/null +++ b/src/pool.rs @@ -0,0 +1,482 @@ +//! A macula 12 node's set of station links, as macula's client pool keeps +//! them: one link to each seed station, every seed pinned by its node_id, +//! all links of one node sharing its identity key, statement issuer, request +//! admission, publication seq and event dedup. A link that ends is dialed +//! again after the respawn delay and given back the node's subscriptions and +//! served procedures. +//! +//! Calls reach providers directly, as macula 12 calls them: the procedure's +//! advertisements are resolved from the DHT and checked against the realm key +//! the pool pins for the realm, the serving station an advertisement names is +//! dialed (pinned by its node_id, from its own station_endpoint record) and +//! the provider called there. Station procedures (`_dht.*`) go to the pool's +//! links. +//! +//! Station discovery beyond the seeds is not here: macula's discovery calls +//! hecate_stations.list_stations, which the fleet no longer serves +//! (macula-io/macula#31). + +mod call; +mod member; +mod pubsub; +mod serve; + +pub use call::{Call, Provider, StreamCall}; +pub use pubsub::Subscription; +pub use serve::{Offer, Served}; + +use std::collections::HashMap; +use std::fmt; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use crate::node_key::{carried_key_well_formed, NodeKey, Purpose}; +use crate::statement_issuer::{IssuerError, StatementIssuer}; +use crate::station_link::{ + Admission, AdmissionLimits, EventDedup, Link, LinkError, PublicationSeq, +}; +use crate::transport::Target; + +use member::Member; + +/// macula's defaults and caps for a pool's bounds. +pub const DEFAULT_REPLICATION_FACTOR: usize = 2; +pub const DEFAULT_RESPAWN_DELAY: Duration = Duration::from_secs(1); +pub const DEFAULT_MAX_SEEDS: usize = 16; +pub const DEFAULT_MAX_DIRECT_LINKS: usize = 8; +pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_LINK_LIMIT: usize = 64; + +/// Why a pool, or one of its operations, failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PoolError { + /// A pool given no seed station. + NoSeeds, + /// A seed without the station's node_id, as host:port: a pool dials + /// only stations it can check. + SeedNotPinned(String), + /// More seeds than `max_seeds`. + TooManySeeds { given: usize, max: usize }, + /// A realm key that is not a well-formed key of the pool's profile, and + /// its realm. + RealmTrustInvalid([u8; 32]), + /// An option out of its range, or a key that is no identity key. + InvalidOpts(String), + /// No link came up within the connect timeout, or none is up to carry an + /// operation; each link's last error. + NoLink(Vec), + /// An operation on a closed pool. + Closed, + /// A realm the pool pins no key for: nothing in it is served or trusted. + NoRealmKey, + /// No provider the pinned realm key authorizes answered: each candidate + /// tried and why it failed; none when there was no candidate at all. + NoProvider(Vec<(Provider, PoolError)>), + /// A serving station with no endpoint record it signed itself. + NoStationEndpoint(Option), + /// A serving station not yet linked while `max_direct_links` direct + /// links are held. + DirectLinksFull, + /// A station that could not be linked, and the last dial's error. + StationNotReached { + station: [u8; 32], + cause: Option, + }, + /// A procedure no link would serve, and each link's error. + NotServed(Vec), + /// A link's own failure, or a provider's or station's answer. + Link(LinkError), +} + +impl fmt::Display for PoolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PoolError::Link(e) => write!(f, "{e}"), + PoolError::NoProvider(tried) if tried.is_empty() => { + f.write_str("no trusted provider advertises the procedure") + } + PoolError::NoProvider(tried) => { + f.write_str("no trusted provider answered:")?; + for (p, e) in tried { + write!(f, " [{} at {}: {e}]", short(&p.node), short(&p.station))?; + } + Ok(()) + } + other => write!(f, "{other:?}"), + } + } +} + +impl std::error::Error for PoolError {} + +impl From for PoolError { + fn from(e: LinkError) -> Self { + PoolError::Link(e) + } +} + +fn short(id: &[u8; 32]) -> String { + id[..4].iter().map(|b| format!("{b:02x}")).collect() +} + +/// A station to link to: where it is dialed and the node_id it must prove. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Seed { + pub host: String, + pub port: u16, + pub node_id: [u8; 32], +} + +/// The order calls, publications and DHT operations try the pool's links in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LinkSelection { + /// The links in seed order. + #[default] + FirstSuccess, + /// A fresh random order each time. + Random, +} + +/// A link coming up, or ending or failing to dial with its error. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinkEvent { + pub station: [u8; 32], + pub direct: bool, + pub up: bool, + pub error: Option, +} + +/// A pool's configuration. [`Opts::new`] gives macula's defaults; a zero +/// bound also means its default. +#[derive(Clone)] +pub struct Opts { + /// The node's identity key; its profile is the pool's. + pub identity: Arc, + /// Each realm's key as carried: an advertisement in a realm is trusted + /// only when its authorization verifies against it, and an org + /// procedure is served only in a realm it names. + pub realm_trust: HashMap<[u8; 32], Vec>, + pub replication_factor: usize, + pub respawn_delay: Duration, + pub max_seeds: usize, + pub max_direct_links: usize, + /// How long [`Pool::connect`] waits for a first link. + pub connect_timeout: Duration, + /// Bounds on the requests the node's served procedures take; `None` is + /// macula's defaults, with the cap one share per link the pool may hold. + pub admission: Option, + pub link_selection: LinkSelection, + /// Hears every link coming up and going down, on a task of its own. + pub on_link_event: Option>, + /// Hears each failure to reissue the node's status statements or rotate + /// its CONNECT key; `None` writes it to stderr. Left failing, the links + /// end when their statements lapse. + pub on_issuer_error: Option>, +} + +impl Opts { + /// macula's defaults for `identity`, trusting no realm. + pub fn new(identity: Arc) -> Opts { + Opts { + identity, + realm_trust: HashMap::new(), + replication_factor: DEFAULT_REPLICATION_FACTOR, + respawn_delay: DEFAULT_RESPAWN_DELAY, + max_seeds: DEFAULT_MAX_SEEDS, + max_direct_links: DEFAULT_MAX_DIRECT_LINKS, + connect_timeout: DEFAULT_CONNECT_TIMEOUT, + admission: None, + link_selection: LinkSelection::FirstSuccess, + on_link_event: None, + on_issuer_error: None, + } + } +} + +/// A node's station links. Cloning it shares the pool; the pool closes when +/// [`Pool::close`] is called or its last handle is dropped. +#[derive(Clone)] +pub struct Pool { + inner: Arc, +} + +pub(crate) struct PoolInner { + opts: Opts, + self_id: [u8; 32], + issuer: StatementIssuer, + publication_seq: Arc, + admission: Arc, + dedup: Arc, + state: Mutex, + ticks: tokio::task::JoinHandle<()>, +} + +struct State { + members: Vec>, + subs: HashMap>, + served: HashMap>, + remember: HashMap, + closed: bool, +} + +/// One of the pool's links. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinkStatus { + pub station: [u8; 32], + pub host: String, + pub port: u16, + pub direct: bool, + pub up: bool, +} + +impl Pool { + /// Checks `seeds` and `opts`, dials every seed, and returns once one link + /// is up, or [`PoolError::NoLink`] with each link's last error when the + /// connect timeout passes first. Links not yet up keep dialing. + pub async fn connect(seeds: Vec, opts: Opts) -> Result { + let opts = checked(&seeds, opts)?; + let self_id = opts + .identity + .node_id() + .map_err(|e| PoolError::InvalidOpts(e.to_string()))?; + let issuer = StatementIssuer::with_wall_clock(opts.identity.clone()) + .map_err(|e| PoolError::InvalidOpts(e.to_string()))?; + let on_error = opts.on_issuer_error.clone(); + let ticks = issuer.spawn_ticks(move |e| match &on_error { + Some(f) => f(e), + None => eprintln!("macula-rust pool: the statement issuer failed: {e}"), + }); + let admission = opts.admission.expect("checked fills the admission limits"); + let inner = Arc::new(PoolInner { + self_id, + issuer, + publication_seq: Arc::default(), + admission: Arc::new(Admission::new(admission)), + dedup: Arc::default(), + state: Mutex::new(State { + members: Vec::new(), + subs: HashMap::new(), + served: HashMap::new(), + remember: HashMap::new(), + closed: false, + }), + ticks, + opts, + }); + let pool = Pool { inner }; + for seed in &seeds { + pool.inner.start_member(pool.target(seed), false); + } + let deadline = tokio::time::Instant::now() + pool.inner.opts.connect_timeout; + if let Err(e) = pool.inner.await_up(deadline).await { + pool.close().await; + return Err(e); + } + Ok(pool) + } + + fn target(&self, seed: &Seed) -> Target { + Target { + host: seed.host.clone(), + port: seed.port, + profile: self.inner.opts.identity.profile(), + expected_node_id: seed.node_id, + } + } + + /// The node_id the pool links as. + pub fn node_id(&self) -> [u8; 32] { + self.inner.self_id + } + + /// Every link the pool holds, seeds first. + pub fn status(&self) -> Vec { + let members = self.inner.lock().members.clone(); + members + .iter() + .map(|m| LinkStatus { + station: m.target.expected_node_id, + host: m.target.host.clone(), + port: m.target.port, + direct: m.direct, + up: m.current().is_some(), + }) + .collect() + } + + /// Ends every link with a GOODBYE and every subscription. It withdraws + /// nothing: an advertisement lapses with its link. + pub async fn close(&self) { + let (members, subs) = { + let mut state = self.inner.lock(); + if state.closed { + return; + } + state.closed = true; + state.served.clear(); + ( + std::mem::take(&mut state.members), + std::mem::take(&mut state.subs), + ) + }; + self.inner.ticks.abort(); + for m in &members { + m.retire(); + } + for m in &members { + m.stopped().await; + } + for sub in subs.into_values() { + let _ = sub.end().await; + } + } +} + +impl fmt::Debug for Pool { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Pool") + .field("node_id", &short(&self.inner.self_id)) + .field("links", &self.status()) + .finish() + } +} + +impl PoolInner { + fn lock(&self) -> MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(|p| p.into_inner()) + } + + /// The links up now, in the pool's selection order. + fn links(&self) -> Vec { + let members = self.lock().members.clone(); + let mut up: Vec = members.iter().filter_map(|m| m.current()).collect(); + if self.opts.link_selection == LinkSelection::Random { + shuffle(&mut up); + } + up + } + + /// Waits until a link is up, or `deadline` passes. + async fn await_up(self: &Arc, deadline: tokio::time::Instant) -> Result<(), PoolError> { + loop { + if !self.links().is_empty() { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + let members = self.lock().members.clone(); + return Err(PoolError::NoLink( + members.iter().filter_map(|m| m.last_error()).collect(), + )); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + fn event(&self, e: LinkEvent) { + if let Some(f) = self.opts.on_link_event.clone() { + tokio::spawn(async move { f(e) }); + } + } + + /// The key a procedure's authorization is checked against: the realm's + /// pinned key, or none for a procedure in a node's own namespace, which + /// its advertisement's signature alone authorizes. + fn realm_key_for( + &self, + realm: &[u8; 32], + procedure: &str, + ) -> Result>, PoolError> { + if crate::record::in_own_namespace(procedure) { + return Ok(None); + } + self.opts + .realm_trust + .get(realm) + .cloned() + .map(Some) + .ok_or(PoolError::NoRealmKey) + } +} + +impl Drop for PoolInner { + /// A pool whose last handle is dropped stops dialing and closes its links. + fn drop(&mut self) { + self.ticks.abort(); + let state = self.state.get_mut().unwrap_or_else(|p| p.into_inner()); + for m in &state.members { + m.retire(); + } + } +} + +/// Refuses, before anything is dialed, what macula's pool refuses, and fills +/// in the defaults. +fn checked(seeds: &[Seed], mut opts: Opts) -> Result { + if opts.identity.purpose() != Purpose::Identity { + return Err(PoolError::InvalidOpts("an identity key is required".into())); + } + let profile = opts.identity.profile(); + for (realm, key) in &opts.realm_trust { + if !carried_key_well_formed(key, profile) { + return Err(PoolError::RealmTrustInvalid(*realm)); + } + } + for (name, limit) in [ + ("max_seeds", opts.max_seeds), + ("max_direct_links", opts.max_direct_links), + ("replication_factor", opts.replication_factor), + ] { + if limit > MAX_LINK_LIMIT { + return Err(PoolError::InvalidOpts(format!( + "{name} of {limit}, outside 1 to {MAX_LINK_LIMIT}" + ))); + } + } + let or_default = |v: usize, d: usize| if v == 0 { d } else { v }; + opts.max_seeds = or_default(opts.max_seeds, DEFAULT_MAX_SEEDS); + opts.max_direct_links = or_default(opts.max_direct_links, DEFAULT_MAX_DIRECT_LINKS); + opts.replication_factor = or_default(opts.replication_factor, DEFAULT_REPLICATION_FACTOR); + if opts.respawn_delay.is_zero() { + opts.respawn_delay = DEFAULT_RESPAWN_DELAY; + } + if opts.connect_timeout.is_zero() { + opts.connect_timeout = DEFAULT_CONNECT_TIMEOUT; + } + let admission = opts.admission.unwrap_or_else(|| { + let mut limits = AdmissionLimits::default(); + limits.cap = limits.share * (opts.max_seeds + opts.max_direct_links); + limits + }); + admission + .validate() + .map_err(|e| PoolError::InvalidOpts(e.to_string()))?; + opts.admission = Some(admission); + if seeds.is_empty() { + return Err(PoolError::NoSeeds); + } + if seeds.len() > opts.max_seeds { + return Err(PoolError::TooManySeeds { + given: seeds.len(), + max: opts.max_seeds, + }); + } + if let Some(unpinned) = seeds.iter().find(|s| s.node_id == [0; 32]) { + return Err(PoolError::SeedNotPinned(format!( + "{}:{}", + unpinned.host, unpinned.port + ))); + } + Ok(opts) +} + +/// Fisher-Yates over the system's randomness. +fn shuffle(items: &mut [T]) { + for i in (1..items.len()).rev() { + let mut r = [0u8; 8]; + if aws_lc_rs::rand::fill(&mut r).is_err() { + return; + } + let j = (u64::from_le_bytes(r) % (i as u64 + 1)) as usize; + items.swap(i, j); + } +} diff --git a/src/pool/call.rs b/src/pool/call.rs new file mode 100644 index 0000000..aee751b --- /dev/null +++ b/src/pool/call.rs @@ -0,0 +1,506 @@ +//! Calls and streams that reach a provider at its own station, and the DHT +//! through the pool's links. +//! +//! A call resolves the procedure's advertisements from the DHT, keeps those +//! the realm's pinned key authorizes (or, in a node's own namespace, those +//! that node signed), and tries the freshest first: it dials the serving +//! station the advertisement names, pinned by its node_id from the station's +//! own station_endpoint record, and calls the provider there. It moves on to +//! the next candidate when a station cannot be reached or reports it cannot +//! relay the call, and returns a provider's own answer or error as it is. A +//! candidate that answered is remembered until its advertisement expires. + +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use tokio::time::Instant; + +use crate::cbor::Value; +use crate::frame::StreamMode; +use crate::record::{self, RecordType, Trust, Verified}; +use crate::station_link::{self, Link, LinkError, Stream, DEFAULT_CALL_TIMEOUT}; +use crate::transport::Target; + +use super::{Pool, PoolError, PoolInner}; + +/// No candidate gets less than a second of a call's time. +const MIN_CANDIDATE_SHARE: Duration = Duration::from_secs(1); + +/// A call to a procedure: its realm and name, the provider to call (any +/// trusted one when zero), the payload, how long to wait +/// ([`DEFAULT_CALL_TIMEOUT`] when zero), and a UCAN and its proofs for a +/// gated procedure. +#[derive(Debug, Clone, PartialEq)] +pub struct Call { + pub realm: [u8; 32], + pub procedure: String, + pub provider: [u8; 32], + pub payload: Value, + pub timeout: Duration, + pub token: Option>, + pub proofs: Vec>, +} + +impl Default for Call { + fn default() -> Self { + Call { + realm: [0; 32], + procedure: String::new(), + provider: [0; 32], + payload: Value::Map(Vec::new()), + timeout: Duration::ZERO, + token: None, + proofs: Vec::new(), + } + } +} + +/// A streaming session to open: its realm and name, the provider (any +/// trusted one when zero), the mode, the open's payload, its deadline (the +/// link's default when zero), and a UCAN and its proofs for a gated +/// procedure. Its default mode is server_stream. +#[derive(Debug, Clone, PartialEq)] +pub struct StreamCall { + pub realm: [u8; 32], + pub procedure: String, + pub provider: [u8; 32], + pub mode: StreamMode, + pub payload: Value, + pub deadline: Duration, + pub token: Option>, + pub proofs: Vec>, +} + +impl Default for StreamCall { + fn default() -> Self { + StreamCall { + realm: [0; 32], + procedure: String::new(), + provider: [0; 32], + mode: StreamMode::ServerStream, + payload: Value::Map(Vec::new()), + deadline: Duration::ZERO, + token: None, + proofs: Vec::new(), + } + } +} + +/// A node serving a procedure, and the station it serves from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Provider { + pub node: [u8; 32], + pub station: [u8; 32], +} + +/// A trusted advertisement: its provider, serving station and times. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct Candidate { + provider: Provider, + expires_at: u64, + created_at: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(super) struct ResolvedKey { + realm: [u8; 32], + procedure: String, + provider: [u8; 32], +} + +impl Pool { + /// Calls a procedure at a provider that serves it, as macula 12 calls + /// one. A provider's ERROR comes back as + /// `PoolError::Link(LinkError::Provider { .. })`; when no candidate + /// answers, [`PoolError::NoProvider`] names each one tried. + pub async fn call(&self, c: Call) -> Result { + let inner = &self.inner; + let realm_key = inner.realm_key_for(&c.realm, &c.procedure)?; + let timeout = if c.timeout.is_zero() { + DEFAULT_CALL_TIMEOUT + } else { + c.timeout + }; + let deadline = Instant::now() + timeout; + let key = ResolvedKey { + realm: c.realm, + procedure: c.procedure.clone(), + provider: c.provider, + }; + let candidates = bounded(deadline, inner.candidates(&key, realm_key)).await?; + let mut tried = Vec::new(); + let count = candidates.len(); + for (i, cand) in candidates.into_iter().enumerate() { + let share = candidate_share(deadline, count - i); + let outcome = bounded(share, inner.call_at(&cand, &c, share)).await; + match outcome { + Ok(v) => { + inner.remember(key, cand); + return Ok(v); + } + Err(e @ PoolError::Link(LinkError::Provider { .. })) => { + inner.remember(key, cand); + return Err(e); + } + Err(e) => { + inner.forget(&key); + tried.push((cand.provider, e)); + if Instant::now() >= deadline { + break; + } + } + } + } + Err(PoolError::NoProvider(tried)) + } + + /// Every provider whose advertisement of `procedure` in `realm` the + /// realm's pinned key authorizes, with the station each serves from, + /// freshest first. + pub async fn providers( + &self, + realm: &[u8; 32], + procedure: &str, + ) -> Result, PoolError> { + let realm_key = self.inner.realm_key_for(realm, procedure)?; + let key = ResolvedKey { + realm: *realm, + procedure: procedure.to_string(), + provider: [0; 32], + }; + let found = self.inner.resolve(&key, realm_key).await?; + Ok(found.into_iter().map(|c| c.provider).collect()) + } + + /// Opens a streaming session at a provider of the procedure, reached as + /// [`Pool::call`] reaches one. The stream is open once its STREAM_OPEN + /// is sent; a provider's or station's refusal arrives on its first recv. + pub async fn open_stream(&self, c: StreamCall) -> Result { + let inner = &self.inner; + let realm_key = inner.realm_key_for(&c.realm, &c.procedure)?; + let deadline = Instant::now() + DEFAULT_CALL_TIMEOUT; + let key = ResolvedKey { + realm: c.realm, + procedure: c.procedure.clone(), + provider: c.provider, + }; + let candidates = bounded(deadline, inner.candidates(&key, realm_key)).await?; + let mut tried = Vec::new(); + let count = candidates.len(); + for (i, cand) in candidates.into_iter().enumerate() { + let share = candidate_share(deadline, count - i); + match bounded(share, inner.open_at(&cand, &c, share)).await { + Ok(stream) => { + inner.remember(key, cand); + return Ok(stream); + } + Err(e) => { + inner.forget(&key); + tried.push((cand.provider, e)); + if Instant::now() >= deadline { + break; + } + } + } + } + Err(PoolError::NoProvider(tried)) + } + + /// Where `station` is dialed, from the station_endpoint record the + /// station signed itself; one another key signed is refused. + pub async fn station_target(&self, station: &[u8; 32]) -> Result { + self.inner.station_target(station).await + } + + /// A link to `station`: one the pool holds, or a direct link it dials to + /// the address in the station's own endpoint record, pinned by its + /// node_id, within the default call timeout. A direct link that does not + /// come up on its first dial is not kept. + pub async fn link_to(&self, station: &[u8; 32]) -> Result { + let deadline = Instant::now() + DEFAULT_CALL_TIMEOUT; + self.inner.link_to(station, deadline).await + } + + /// The record under `key`, verified, from the first link that answers. + pub async fn find_record(&self, key: &[u8; 32]) -> Result { + self.inner + .first_answer(|l| async move { l.find_record(key).await }) + .await + } + + /// The records under `key` that verify, and how many did not, from the + /// first link that answers. + pub async fn find_records(&self, key: &[u8; 32]) -> Result<(Vec, usize), PoolError> { + self.inner + .first_answer(|l| async move { l.find_records(key).await }) + .await + } + + /// The records of type `t` that verify, and how many did not, from the + /// first link that answers. + pub async fn find_records_by_type( + &self, + t: RecordType, + ) -> Result<(Vec, usize), PoolError> { + self.inner + .first_answer(|l| async move { l.find_records_by_type(t).await }) + .await + } + + /// Puts a signed record through the first link whose station takes it. + pub async fn put_record(&self, wire: &[u8]) -> Result<(), PoolError> { + self.inner + .first_answer(|l| async move { l.put_record(wire).await }) + .await + } +} + +impl PoolInner { + fn remember(&self, key: ResolvedKey, cand: Candidate) { + self.lock().remember.insert(key, cand); + } + + fn forget(&self, key: &ResolvedKey) { + self.lock().remember.remove(key); + } + + /// The remembered candidate while it lives and its station is linked, + /// else the procedure's trusted advertisements from the DHT. + async fn candidates( + &self, + key: &ResolvedKey, + realm_key: Option>, + ) -> Result, PoolError> { + let remembered = self.lock().remember.get(key).copied(); + if let Some(cand) = remembered { + if cand.expires_at as i64 > now_ms() && self.linked_to(&cand.provider.station).is_some() + { + return Ok(vec![cand]); + } + } + self.resolve(key, realm_key).await + } + + /// The procedure's advertisements the realm key authorizes, by + /// `key.provider` when it is set, freshest first. + async fn resolve( + &self, + key: &ResolvedKey, + realm_key: Option>, + ) -> Result, PoolError> { + let slot = record::procedure_key(&key.realm, &key.procedure); + let (found, _) = self + .first_answer(|l| async move { l.find_records(&slot).await }) + .await?; + let now = now_ms(); + let trust = Trust { + profile: self.opts.identity.profile(), + realm_key, + }; + let mut out: Vec = found + .iter() + .filter(|v| v.record().record_type == RecordType::PROCEDURE_ADVERTISEMENT) + .filter_map(|v| { + let ad = record::read_procedure_advertisement(v.record()).ok()?; + let wanted = ad.realm_id == key.realm + && ad.procedure == key.procedure + && (key.provider == [0; 32] || ad.advertiser_node == key.provider); + if !wanted || record::verify_authorization(v, &trust, now).is_err() { + return None; + } + Some(Candidate { + provider: Provider { + node: ad.advertiser_node, + station: ad.serving_station, + }, + expires_at: v.record().expires_at, + created_at: v.record().created_at, + }) + }) + .collect(); + if out.is_empty() { + return Err(PoolError::NoProvider(Vec::new())); + } + out.sort_by_key(|c| std::cmp::Reverse(c.created_at)); + Ok(out) + } + + async fn call_at( + self: &Arc, + cand: &Candidate, + c: &Call, + share: Instant, + ) -> Result { + let link = self.link_to(&cand.provider.station, share).await?; + let left = share.saturating_duration_since(Instant::now()); + Ok(link + .call(station_link::Call { + realm: c.realm, + procedure: c.procedure.clone(), + target: cand.provider.node, + payload: c.payload.clone(), + timeout: left.max(Duration::from_millis(1)), + token: c.token.clone(), + proofs: c.proofs.clone(), + }) + .await?) + } + + async fn open_at( + self: &Arc, + cand: &Candidate, + c: &StreamCall, + share: Instant, + ) -> Result { + let link = self.link_to(&cand.provider.station, share).await?; + Ok(link + .open_stream(station_link::StreamCall { + realm: c.realm, + procedure: c.procedure.clone(), + target: cand.provider.node, + mode: c.mode, + payload: c.payload.clone(), + deadline: c.deadline, + token: c.token.clone(), + proofs: c.proofs.clone(), + }) + .await?) + } + + /// The link up now to `station`, if any. + fn linked_to(&self, station: &[u8; 32]) -> Option { + self.links() + .into_iter() + .find(|l| l.station_node_id() == *station) + } + + async fn link_to( + self: &Arc, + station: &[u8; 32], + deadline: Instant, + ) -> Result { + if let Some(link) = self.linked_to(station) { + return Ok(link); + } + let (existing, direct) = { + let state = self.lock(); + if state.closed { + return Err(PoolError::Closed); + } + let existing = state + .members + .iter() + .find(|m| m.target.expected_node_id == *station) + .cloned(); + (existing, state.members.iter().filter(|m| m.direct).count()) + }; + let fresh = existing.is_none(); + let member = match existing { + Some(m) => m, + None => { + if direct >= self.opts.max_direct_links { + return Err(PoolError::DirectLinksFull); + } + let target = bounded(deadline, self.station_target(station)).await?; + self.start_member(target, true) + } + }; + if let Some(link) = member.await_up(deadline, fresh).await { + return Ok(link); + } + if fresh { + self.drop_member(&member); + } + Err(PoolError::StationNotReached { + station: *station, + cause: member.last_error(), + }) + } + + async fn station_target(&self, station: &[u8; 32]) -> Result { + let slot = record::station_endpoint_key(station); + let verified = self + .first_answer(|l| async move { l.find_record(&slot).await }) + .await + .map_err(|e| match e { + PoolError::Link(link) => PoolError::NoStationEndpoint(Some(link)), + other => other, + })?; + let r = verified.record(); + let signer = r.signed.as_ref().map(|s| s.key_id); + let endpoint = record::read_station_endpoint(r) + .map_err(|e| PoolError::NoStationEndpoint(Some(e.into())))?; + match (signer, endpoint.host_advertised.first()) { + (Some(signer), Some(host)) if signer == *station && endpoint.quic_port != 0 => { + Ok(Target { + host: host.clone(), + port: endpoint.quic_port, + profile: self.opts.identity.profile(), + expected_node_id: *station, + }) + } + _ => Err(PoolError::NoStationEndpoint(None)), + } + } + + /// Runs `ask` on the links in selection order and returns the first + /// answer, moving on only when a link could not carry the request: a + /// station's own answer, not_found included, is final. + async fn first_answer<'a, T, F, Fut>(&self, ask: F) -> Result + where + F: Fn(Link) -> Fut, + Fut: Future> + 'a, + { + let links = self.links(); + if links.is_empty() { + return Err(PoolError::NoLink(Vec::new())); + } + let mut errors = Vec::new(); + for link in links { + match ask(link).await { + Err(e) if unreachable(&e) => errors.push(e), + answered => return answered.map_err(PoolError::Link), + } + } + Err(PoolError::NoLink(errors)) + } +} + +/// A failure of the link to carry a request, as opposed to the station's +/// answer: no reply in time, or the link ended. +fn unreachable(e: &LinkError) -> bool { + matches!( + e, + LinkError::CallTimeout + | LinkError::Closed + | LinkError::LivenessLost + | LinkError::Io(_) + | LinkError::Goodbye(_) + | LinkError::StatusExpired + | LinkError::BindingExpired + ) +} + +/// One candidate's part of what is left before `deadline` with `left` +/// candidates to try, at least a second, as macula shares it, and never past +/// `deadline` itself. +fn candidate_share(deadline: Instant, left: usize) -> Instant { + let now = Instant::now(); + let remaining = deadline.saturating_duration_since(now); + (now + (remaining / left.max(1) as u32).max(MIN_CANDIDATE_SHARE)).min(deadline) +} + +/// `work` bounded by `deadline`: past it, the call timed out. +async fn bounded( + deadline: Instant, + work: impl Future>, +) -> Result { + tokio::time::timeout_at(deadline, work) + .await + .unwrap_or(Err(PoolError::Link(LinkError::CallTimeout))) +} + +fn now_ms() -> i64 { + crate::uuid_v7::now_ms() as i64 +} diff --git a/src/pool/member.rs b/src/pool/member.rs new file mode 100644 index 0000000..a44d66c --- /dev/null +++ b/src/pool/member.rs @@ -0,0 +1,173 @@ +//! One station the pool links to, a seed or a station dialed directly for a +//! call: it dials the station, and when the link ends dials it again after +//! the respawn delay, until it is retired. + +use std::sync::{Arc, Weak}; + +use tokio::sync::watch; + +use crate::station_link::{Config, Link, LinkError}; +use crate::transport::Target; + +use super::{LinkEvent, PoolInner}; + +pub(super) struct Member { + pub(super) target: Target, + pub(super) direct: bool, + state: watch::Sender, + retired: watch::Sender, + stopped: watch::Sender, +} + +/// What a member holds now: its link while one is up, the last error, and +/// how many dials have failed. +#[derive(Clone, Default)] +struct Held { + link: Option, + error: Option, + failed_dials: u64, +} + +impl PoolInner { + pub(super) fn start_member(self: &Arc, target: Target, direct: bool) -> Arc { + let m = Arc::new(Member { + target, + direct, + state: watch::channel(Held::default()).0, + retired: watch::channel(false).0, + stopped: watch::channel(false).0, + }); + self.lock().members.push(m.clone()); + tokio::spawn(supervise(Arc::downgrade(self), m.clone())); + m + } + + /// Retires a member the pool no longer keeps: its link closes, it is not + /// dialed again, and it leaves the pool's members. + pub(super) fn drop_member(&self, m: &Arc) { + m.retire(); + self.lock().members.retain(|held| !Arc::ptr_eq(held, m)); + } +} + +impl Member { + pub(super) fn current(&self) -> Option { + self.state.borrow().link.clone() + } + + pub(super) fn last_error(&self) -> Option { + self.state.borrow().error.clone() + } + + pub(super) fn retire(&self) { + let _ = self.retired.send_replace(true); + } + + /// Waits until the member's link has closed after it was retired. + pub(super) async fn stopped(&self) { + let mut stopped = self.stopped.subscribe(); + let _ = stopped.wait_for(|s| *s).await; + } + + /// The member's link once it is up, or `None` when `deadline` passes, the + /// member is retired, or, with `fail_fast`, when its next dial fails. + pub(super) async fn await_up( + &self, + deadline: tokio::time::Instant, + fail_fast: bool, + ) -> Option { + let mut state = self.state.subscribe(); + let mut retired = self.retired.subscribe(); + let failures_at_start = state.borrow().failed_dials; + loop { + { + let held = state.borrow_and_update(); + if let Some(link) = &held.link { + return Some(link.clone()); + } + if fail_fast && held.failed_dials > failures_at_start { + return None; + } + } + tokio::select! { + changed = state.changed() => if changed.is_err() { return None }, + _ = retired.wait_for(|r| *r) => return None, + _ = tokio::time::sleep_until(deadline) => return None, + } + } + } +} + +/// Dials the member's station, holds the link until it ends or the member is +/// retired, and dials again after the respawn delay. +async fn supervise(pool: Weak, m: Arc) { + let mut retired = m.retired.subscribe(); + loop { + let Some(inner) = pool.upgrade() else { break }; + let respawn = inner.opts.respawn_delay; + let mut cfg = Config::new( + m.target.clone(), + inner.opts.identity.clone(), + inner.issuer.clone(), + ); + cfg.publication_seq = Some(inner.publication_seq.clone()); + cfg.admission = Some(inner.admission.clone()); + cfg.dedup = Some(inner.dedup.clone()); + drop(inner); + let dialed = tokio::select! { + dialed = Link::dial(cfg) => dialed, + _ = retired.wait_for(|r| *r) => break, + }; + match dialed { + Err(e) => { + m.state.send_modify(|h| { + h.error = Some(e.clone()); + h.failed_dials += 1; + }); + event(&pool, &m, false, Some(e)); + } + Ok(link) => { + m.state.send_modify(|h| { + h.link = Some(link.clone()); + h.error = None; + }); + event(&pool, &m, true, None); + if let Some(inner) = pool.upgrade() { + inner.replay(&link).await; + } + let retiring = tokio::select! { + _ = link.done() => false, + _ = retired.wait_for(|r| *r) => true, + }; + if retiring { + let _ = link.close("client_stop").await; + } + let ended = link.error(); + m.state.send_modify(|h| { + h.link = None; + h.error = ended.clone(); + }); + event(&pool, &m, false, ended); + } + } + if *retired.borrow() { + break; + } + tokio::select! { + _ = retired.wait_for(|r| *r) => break, + _ = tokio::time::sleep(respawn) => {} + } + } + let _ = m.stopped.send_replace(true); +} + +fn event(pool: &Weak, m: &Member, up: bool, error: Option) { + if let Some(inner) = pool.upgrade() { + inner.event(LinkEvent { + station: m.target.expected_node_id, + direct: m.direct, + up, + error, + }); + } +} diff --git a/src/pool/pubsub.rs b/src/pool/pubsub.rs new file mode 100644 index 0000000..892021f --- /dev/null +++ b/src/pool/pubsub.rs @@ -0,0 +1,245 @@ +//! PubSub through the pool: a subscription on every link the pool holds and +//! every link it dials later, each event delivered once whichever links hear +//! it; a publication signed once and sent on the first replication_factor +//! links. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, Weak}; + +use tokio::sync::{mpsc, oneshot}; + +use crate::station_link::{self, Event, Link, LinkError, Publication, SignedPublication}; + +use super::{Pool, PoolError, PoolInner}; + +/// How many events a subscription holds that its reader has not taken; one +/// arriving at a full subscription is dropped and counted. +const SUBSCRIPTION_BUFFER: usize = 256; + +static NEXT_SUBSCRIPTION: AtomicU64 = AtomicU64::new(1); + +/// The node's subscription to a realm and topic, on every link the pool +/// holds and every link it dials later, until [`Subscription::unsubscribe`] +/// or the pool closes. +pub struct Subscription { + inner: Arc, + events: mpsc::Receiver, + unsubscribed: bool, +} + +pub(super) struct SubInner { + id: u64, + pool: Weak, + realm: [u8; 32], + topic: String, + held: Mutex, + dropped: AtomicU64, +} + +struct Held { + /// `None` once the subscription ended. + events: Option>, + /// Each link's forwarder, by link serial: told to stop, it unsubscribes + /// on its link and says how that went. + on_links: HashMap, +} + +struct Forwarder { + stop: oneshot::Sender<()>, + unsubscribed: oneshot::Receiver>, +} + +impl Pool { + /// Subscribes the node to `topic` in `realm` on every link. + pub async fn subscribe( + &self, + realm: &[u8; 32], + topic: &str, + ) -> Result { + let (events_tx, events) = mpsc::channel(SUBSCRIPTION_BUFFER); + let sub = Arc::new(SubInner { + id: NEXT_SUBSCRIPTION.fetch_add(1, Ordering::Relaxed), + pool: Arc::downgrade(&self.inner), + realm: *realm, + topic: topic.to_string(), + held: Mutex::new(Held { + events: Some(events_tx), + on_links: HashMap::new(), + }), + dropped: AtomicU64::new(0), + }); + { + let mut state = self.inner.lock(); + if state.closed { + return Err(PoolError::Closed); + } + state.subs.insert(sub.id, sub.clone()); + } + for link in self.inner.links() { + sub.attach(&link).await; + } + Ok(Subscription { + inner: sub, + events, + unsubscribed: false, + }) + } + + /// Signs `p` once and sends it on the first replication_factor links, in + /// the pool's selection order, succeeding when one of them takes it. + /// Every copy is the same publication, so a subscriber delivers it once. + pub async fn publish(&self, p: Publication) -> Result<(), PoolError> { + let links = self.inner.links(); + if links.is_empty() { + return Err(PoolError::NoLink(Vec::new())); + } + let signed = + SignedPublication::sign(&self.inner.opts.identity, &self.inner.publication_seq, p)?; + let mut errors = Vec::new(); + let mut sent = 0; + for link in links.iter().take(self.inner.opts.replication_factor) { + match link.publish_signed(&signed).await { + Ok(()) => sent += 1, + Err(e) => errors.push(e), + } + } + if sent == 0 { + return Err(PoolError::NoLink(errors)); + } + Ok(()) + } +} + +impl Subscription { + /// The next event, once, whichever links heard it; `None` once the + /// subscription or the pool has ended. + pub async fn recv(&mut self) -> Option { + self.events.recv().await + } + + /// How many events arrived while the subscription was full. + pub fn dropped(&self) -> u64 { + self.inner.dropped.load(Ordering::Relaxed) + } + + /// Ends the subscription on every link. + pub async fn unsubscribe(&mut self) -> Result<(), LinkError> { + self.unsubscribed = true; + if let Some(pool) = self.inner.pool.upgrade() { + pool.lock().subs.remove(&self.inner.id); + } + self.inner.end().await + } +} + +impl Drop for Subscription { + /// A subscription dropped without unsubscribing unsubscribes as it goes. + fn drop(&mut self) { + if self.unsubscribed { + return; + } + if let Some(pool) = self.inner.pool.upgrade() { + pool.lock().subs.remove(&self.inner.id); + } + let inner = self.inner.clone(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + let _ = inner.end().await; + }); + } + } +} + +impl SubInner { + fn lock(&self) -> MutexGuard<'_, Held> { + self.held.lock().unwrap_or_else(|p| p.into_inner()) + } + + /// Ends the subscription once: its events end, and every link + /// unsubscribes. + pub(super) async fn end(&self) -> Result<(), LinkError> { + let forwarders = { + let mut held = self.lock(); + if held.events.take().is_none() { + return Ok(()); + } + std::mem::take(&mut held.on_links) + }; + let mut result = Ok(()); + for (_, f) in forwarders { + let _ = f.stop.send(()); + if let Ok(Err(e)) = f.unsubscribed.await { + result = Err(e); + } + } + result + } + + /// Subscribes on `link`, once, and forwards what it hears until the + /// link's subscription ends. + pub(super) async fn attach(self: &Arc, link: &Link) { + let events = { + let held = self.lock(); + match &held.events { + Some(events) if !held.on_links.contains_key(&link.serial()) => events.clone(), + _ => return, + } + }; + let Ok(on_link) = link.subscribe(&self.realm, &self.topic).await else { + return; + }; + let (stop, stopped) = oneshot::channel(); + let (unsubscribed_tx, unsubscribed) = oneshot::channel(); + let kept = { + let mut held = self.lock(); + let wanted = held.events.is_some() && !held.on_links.contains_key(&link.serial()); + if wanted { + held.on_links + .insert(link.serial(), Forwarder { stop, unsubscribed }); + } + wanted + }; + if !kept { + let _ = on_link.unsubscribe().await; + return; + } + tokio::spawn(forward( + self.clone(), + link.serial(), + on_link, + events, + stopped, + unsubscribed_tx, + )); + } +} + +async fn forward( + sub: Arc, + serial: u64, + mut on_link: station_link::Subscription, + events: mpsc::Sender, + mut stop: oneshot::Receiver<()>, + unsubscribed: oneshot::Sender>, +) { + loop { + tokio::select! { + _ = &mut stop => { + let _ = unsubscribed.send(on_link.unsubscribe().await); + return; + } + event = on_link.recv() => match event { + Some(event) => { + if events.try_send(event).is_err() { + sub.dropped.fetch_add(1, Ordering::Relaxed); + } + } + None => break, + }, + } + } + // The link ended: a link dialed again gets the subscription back. + sub.lock().on_links.remove(&serial); + let _ = unsubscribed.send(Ok(())); +} diff --git a/src/pool/serve.rs b/src/pool/serve.rs new file mode 100644 index 0000000..1f67955 --- /dev/null +++ b/src/pool/serve.rs @@ -0,0 +1,244 @@ +//! Serving through the pool: a procedure served on every link the pool +//! holds and every link it dials later, each advertising it naming its own +//! station, until stopped. An org procedure is served only in a realm the +//! pool pins a key for; one in the node's own namespace needs none. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, Weak}; +use std::time::Duration; + +use crate::frame::StreamMode; +use crate::station_link::{ + self, Handler, Link, LinkError, StreamHandler, StreamOffer, DEFAULT_CALL_TIMEOUT, +}; + +use super::{Pool, PoolError, PoolInner}; + +static NEXT_SERVED: AtomicU64 = AtomicU64::new(1); + +/// A procedure the node serves: its realm, which the pool must pin a key for +/// unless the procedure is in the node's own namespace, its name, and +/// exactly one of a unary handler and a stream offer. +#[derive(Clone)] +pub struct Offer { + pub realm: [u8; 32], + pub procedure: String, + pub handler: Option, + pub stream: Option, +} + +impl Offer { + /// A unary procedure. + pub fn unary(realm: [u8; 32], procedure: &str, handler: Handler) -> Offer { + Offer { + realm, + procedure: procedure.to_string(), + handler: Some(handler), + stream: None, + } + } + + /// A streaming procedure of `mode`. + pub fn stream( + realm: [u8; 32], + procedure: &str, + mode: StreamMode, + handler: StreamHandler, + ) -> Offer { + Offer { + realm, + procedure: procedure.to_string(), + handler: None, + stream: Some(StreamOffer { mode, handler }), + } + } +} + +/// A procedure the node serves on every link the pool holds, and on every +/// link it dials later, until [`Served::stop`]. +pub struct Served { + inner: Arc, +} + +pub(super) struct ServedInner { + id: u64, + pool: Weak, + offer: station_link::Offer, + held: Mutex, +} + +struct Held { + stopped: bool, + on_links: HashMap, +} + +impl Pool { + /// Serves `o` on every link that is up, and on every link that comes up + /// after. It succeeds when one link serves it; each link advertises it + /// naming its own station, and renews it and puts it in the DHT as + /// [`Link::serve`] does. + pub async fn serve(&self, o: Offer) -> Result { + let realm_key = self.inner.realm_key_for(&o.realm, &o.procedure)?; + let served = Arc::new(ServedInner { + id: NEXT_SERVED.fetch_add(1, Ordering::Relaxed), + pool: Arc::downgrade(&self.inner), + offer: station_link::Offer { + realm: o.realm, + procedure: o.procedure, + handler: o.handler, + stream: o.stream, + realm_key, + }, + held: Mutex::new(Held { + stopped: false, + on_links: HashMap::new(), + }), + }); + let links = self.inner.links(); + if links.is_empty() { + return Err(PoolError::NoLink(Vec::new())); + } + let mut errors = Vec::new(); + for link in &links { + if let Err(e) = served.serve_on(link).await { + errors.push(e); + } + } + if errors.len() == links.len() { + return Err(PoolError::NotServed(errors)); + } + { + let mut state = self.inner.lock(); + if state.closed { + return Err(PoolError::Closed); + } + state.served.insert(served.id, served.clone()); + } + for link in self.inner.links() { + served.attach(link); + } + Ok(Served { inner: served }) + } +} + +impl PoolInner { + /// Gives a new link the node's subscriptions, then its served + /// procedures, as macula's pool replays them on a respawned link. + pub(super) async fn replay(&self, link: &Link) { + let (subs, served) = { + let state = self.lock(); + ( + state.subs.values().cloned().collect::>(), + state.served.values().cloned().collect::>(), + ) + }; + for sub in subs { + sub.attach(link).await; + } + for s in served { + s.attach(link.clone()); + } + } +} + +impl Served { + /// Withdraws the procedure on every link. + pub async fn stop(&self) -> Result<(), LinkError> { + if let Some(pool) = self.inner.pool.upgrade() { + pool.lock().served.remove(&self.inner.id); + } + let on_links = { + let mut held = self.inner.lock(); + if held.stopped { + return Ok(()); + } + held.stopped = true; + std::mem::take(&mut held.on_links) + }; + let mut result = Ok(()); + for on_link in on_links.into_values() { + if let Err(e) = on_link.stop().await { + result = Err(e); + } + } + result + } +} + +impl ServedInner { + fn lock(&self) -> MutexGuard<'_, Held> { + self.held.lock().unwrap_or_else(|p| p.into_inner()) + } + + fn respawn_delay(&self) -> Option { + self.pool.upgrade().map(|p| p.opts.respawn_delay) + } + + /// Serves the offer on `link`, once, and watches it there. + async fn serve_on(self: &Arc, link: &Link) -> Result<(), LinkError> { + { + let held = self.lock(); + if held.stopped || held.on_links.contains_key(&link.serial()) { + return Ok(()); + } + } + let on_link = match link.serve(self.offer.clone()).await { + Err(LinkError::AlreadyServed) => return Ok(()), + other => other?, + }; + { + let mut held = self.lock(); + if !held.stopped { + held.on_links.insert(link.serial(), on_link.clone()); + drop(held); + tokio::spawn(watch(self.clone(), link.clone(), on_link)); + return Ok(()); + } + } + on_link.stop().await + } + + /// Serves the offer on a link the pool dialed, trying again every + /// respawn delay while the link lives and the offer is not served there. + pub(super) fn attach(self: &Arc, link: Link) { + let served = self.clone(); + tokio::spawn(async move { + loop { + let outcome = + tokio::time::timeout(DEFAULT_CALL_TIMEOUT, served.serve_on(&link)).await; + if matches!(outcome, Ok(Ok(()))) { + return; + } + let Some(delay) = served.respawn_delay() else { + return; + }; + tokio::select! { + _ = link.done() => return, + _ = tokio::time::sleep(delay) => {} + } + } + }); + } +} + +/// Forgets a link's serving when it ends, and serves the offer there again +/// when it lapsed while the link lives. +async fn watch(served: Arc, link: Link, on_link: station_link::Served) { + let why = on_link.done().await; + let stopped = { + let mut held = served.lock(); + held.on_links.remove(&link.serial()); + held.stopped + }; + if stopped || why == LinkError::Stopped || link.error().is_some() { + return; + } + let Some(delay) = served.respawn_delay() else { + return; + }; + tokio::select! { + _ = link.done() => {} + _ = tokio::time::sleep(delay) => served.attach(link.clone()), + } +} diff --git a/src/station_link.rs b/src/station_link.rs index a6fdced..5505a20 100644 --- a/src/station_link.rs +++ b/src/station_link.rs @@ -222,6 +222,8 @@ pub struct Link { } struct Inner { + /// Distinct for every link a process dials. + serial: u64, connection: quinn::Connection, _endpoint: quinn::Endpoint, control: FrameWriter, @@ -279,6 +281,11 @@ impl Link { self.inner.station.node_id } + /// A number distinct for every link this process dials. + pub fn serial(&self) -> u64 { + self.inner.serial + } + /// The node_id this link connected as. pub fn node_id(&self) -> [u8; 32] { self.inner.self_id @@ -377,7 +384,9 @@ async fn handshaken(cfg: Config) -> Result { let share = cfg .share .unwrap_or_else(|| format!("{}:{}", cfg.target.host, cfg.target.port)); + static SERIALS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); let inner = Arc::new(Inner { + serial: SERIALS.fetch_add(1, Ordering::Relaxed), connection: dialed.connection, _endpoint: dialed.endpoint, control, @@ -486,7 +495,7 @@ impl Inner { stream::StreamInner::end(&s, Some(err.clone())); } self.connection.close(0u32.into(), b"link ended"); - let _ = self.done_tx.send(true); + let _ = self.done_tx.send_replace(true); } } diff --git a/src/station_link/serve.rs b/src/station_link/serve.rs index dd7279e..ed8a845 100644 --- a/src/station_link/serve.rs +++ b/src/station_link/serve.rs @@ -147,7 +147,8 @@ pub(super) struct ServedInner { /// A procedure this link serves, until [`Served::stop`] or the link ends, or /// until its advertisement lapses because its authorization could not be -/// found again. +/// found again. Cloning it shares the serving. +#[derive(Clone)] pub struct Served { inner: Arc, } @@ -347,7 +348,7 @@ impl ServedInner { state.served.remove(&self.key); } } - let _ = self.done_tx.send(true); + let _ = self.done_tx.send_replace(true); } } diff --git a/src/station_link/stream.rs b/src/station_link/stream.rs index ec9ff6f..3f05f24 100644 --- a/src/station_link/stream.rs +++ b/src/station_link/stream.rs @@ -431,7 +431,7 @@ impl StreamInner { .lock() .streams .retain(|w| w.strong_count() > 0 && !std::ptr::eq(w.as_ptr(), Arc::as_ptr(this))); - let _ = this.done_tx.send(true); + let _ = this.done_tx.send_replace(true); this.notify.notify_waiters(); this.notify.notify_one(); } diff --git a/tests/common/lab.rs b/tests/common/lab.rs new file mode 100644 index 0000000..f1ac7c8 --- /dev/null +++ b/tests/common/lab.rs @@ -0,0 +1,197 @@ +//! The teststation's lab mode: stations and realms started one by one, and +//! the station-side facts a test asserts on (who is connected, what is +//! advertised or subscribed, how many streams are relayed). + +use std::io::BufReader; +use std::process::{Child, ChildStdin, ChildStdout}; +use std::sync::Mutex; + +use macula_rust::pool::Seed; +use macula_rust::profile::Profile; + +use super::{ask, spawn}; + +/// A station the lab started: its index, where it listens, the node_id it +/// proves. +#[derive(Debug, Clone)] +pub struct LabStation { + pub index: usize, + pub host: String, + pub port: u16, + pub node_id: [u8; 32], +} + +impl LabStation { + /// The station as a pool seed, pinned by its node_id. + pub fn seed(&self) -> Seed { + Seed { + host: self.host.clone(), + port: self.port, + node_id: self.node_id, + } + } +} + +/// A test realm with one org: its index, id and realm key as carried. +#[derive(Debug, Clone)] +pub struct LabRealm { + pub index: usize, + pub id: [u8; 32], + pub key: Vec, +} + +/// A teststation in lab mode, killed when dropped. +pub struct Lab { + pub profile: Profile, + child: Child, + io: Mutex<(ChildStdin, BufReader)>, +} + +impl Lab { + /// A lab in `profile`, with no station yet. + pub fn start(profile: Profile) -> Lab { + let (child, stdin, stdout) = spawn(&[profile.name(), "lab"]); + Lab { + profile, + child, + io: Mutex::new((stdin, stdout)), + } + } + + /// A new station named `name`, its own endpoint record in its DHT. + pub fn station(&self, name: &str) -> LabStation { + let reply = self.expect(&format!("start {name}"), "station"); + let f: Vec<&str> = reply.split_whitespace().collect(); + LabStation { + index: f[1].parse().unwrap(), + host: f[2].to_string(), + port: f[3].parse().unwrap(), + node_id: id32(f[4]), + } + } + + /// Stops a station: its listener and every connection close. + pub fn stop(&self, s: &LabStation) { + self.expect(&format!("stop {}", s.index), "stopped"); + } + + /// Closes `node`'s connection at the station, as a restart would. + pub fn drop_node(&self, s: &LabStation, node: &[u8; 32]) { + self.expect( + &format!("drop {} {}", s.index, hex::encode(node)), + "dropped", + ); + } + + pub fn connected(&self, s: &LabStation, node: &[u8; 32]) -> bool { + self.yes(&format!("connected {} {}", s.index, hex::encode(node))) + } + + pub fn advertised(&self, s: &LabStation, realm: &[u8; 32], procedure: &str) -> bool { + self.yes(&format!( + "advertised {} {} {procedure}", + s.index, + hex::encode(realm) + )) + } + + pub fn subscribed( + &self, + s: &LabStation, + node: &[u8; 32], + realm: &[u8; 32], + topic: &str, + ) -> bool { + self.yes(&format!( + "subscribed {} {} {} {topic}", + s.index, + hex::encode(node), + hex::encode(realm) + )) + } + + /// The stations hold one record store, as a replicating DHT would. + pub fn share(&self, stations: &[&LabStation]) { + let indexes: Vec = stations.iter().map(|s| s.index.to_string()).collect(); + self.expect(&format!("share {}", indexes.join(" ")), "shared"); + } + + /// Stores a verified record's wire bytes at the station. + pub fn put(&self, s: &LabStation, wire: &[u8]) { + self.expect(&format!("put {} {}", s.index, hex::encode(wire)), "put"); + } + + /// Stores `wire` under `key` whatever it is, as a lying station would. + pub fn forge(&self, s: &LabStation, key: &[u8; 32], wire: &[u8]) { + self.expect( + &format!( + "forge {} {} {}", + s.index, + hex::encode(key), + hex::encode(wire) + ), + "forged", + ); + } + + /// How many streams the station relays now. + pub fn relayed(&self, s: &LabStation) -> u64 { + let reply = self.expect(&format!("relayed {}", s.index), "relayed"); + reply.split_whitespace().nth(1).unwrap().parse().unwrap() + } + + /// A realm named `name` with the org `org`. + pub fn realm(&self, name: &str, org: &str) -> LabRealm { + self.realm_line(&format!("realm {name} {org}")) + } + + /// A realm with `r`'s id and org and another realm key. + pub fn impostor(&self, r: &LabRealm) -> LabRealm { + self.realm_line(&format!("impostor {}", r.index)) + } + + /// Puts `r`'s org directory, and its org's delegation to each of + /// `advertisers`, in the station's DHT. + pub fn admit(&self, r: &LabRealm, s: &LabStation, advertisers: &[[u8; 32]]) { + let nodes: Vec = advertisers.iter().map(hex::encode).collect(); + self.expect( + &format!("admit {} {} {}", r.index, s.index, nodes.join(" ")), + "admitted", + ); + } + + fn realm_line(&self, command: &str) -> LabRealm { + let reply = self.expect(command, "realm"); + let f: Vec<&str> = reply.split_whitespace().collect(); + LabRealm { + index: f[1].parse().unwrap(), + id: id32(f[2]), + key: hex::decode(f[3]).unwrap(), + } + } + + fn yes(&self, command: &str) -> bool { + match ask(&self.io, command).as_str() { + "yes" => true, + "no" => false, + other => panic!("{command}: {other}"), + } + } + + fn expect(&self, command: &str, answer: &str) -> String { + let reply = ask(&self.io, command); + assert!(reply.starts_with(answer), "{command}: {reply}"); + reply + } +} + +impl Drop for Lab { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn id32(text: &str) -> [u8; 32] { + hex::decode(text).unwrap().try_into().unwrap() +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index ad9fad8..b9fa4ef 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,10 +1,13 @@ -//! Two in-process macula 12 stations sharing a DHT and a test realm with one -//! org, for a test file: macula-go's teststation, built to target/teststation -//! by scripts/build-teststation.sh (or named by MACULA_TESTSTATION), driven -//! over its stdin. +//! In-process macula 12 stations for a test file: macula-go's teststation, +//! built to target/teststation by scripts/build-teststation.sh (or named by +//! MACULA_TESTSTATION), driven over its stdin. [`TestStations`] is two +//! stations sharing a DHT and a test realm with one org; [`lab::Lab`] starts +//! and shapes stations and realms one by one. #![allow(dead_code)] +pub mod lab; + use std::io::{BufRead, BufReader, Write}; use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; use std::sync::Mutex; @@ -36,22 +39,7 @@ impl TestStations { /// naming how to build it: a test that cannot reach its stations proves /// nothing, so it is never skipped. pub fn start(profile: Profile) -> TestStations { - let binary = std::env::var("MACULA_TESTSTATION").unwrap_or_else(|_| { - concat!(env!("CARGO_MANIFEST_DIR"), "/target/teststation").to_string() - }); - assert!( - std::path::Path::new(&binary).exists(), - "{binary} is missing: run scripts/build-teststation.sh first" - ); - let mut child = Command::new(&binary) - .arg(profile.name()) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .spawn() - .expect("the teststation starts"); - let stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let (child, stdin, mut stdout) = spawn(&[profile.name()]); let mut line = String::new(); stdout .read_line(&mut line) @@ -113,15 +101,42 @@ impl TestStations { } fn ask(&self, command: &str) -> String { - let mut io = self.io.lock().unwrap(); - writeln!(io.0, "{command}").unwrap(); - io.0.flush().unwrap(); - let mut line = String::new(); - io.1.read_line(&mut line).unwrap(); - line.trim_end().to_string() + ask(&self.io, command) } } +/// Starts the teststation with `args`. A missing helper fails the test, +/// naming how to build it: a test that cannot reach its stations proves +/// nothing, so it is never skipped. +fn spawn(args: &[&str]) -> (Child, ChildStdin, BufReader) { + let binary = std::env::var("MACULA_TESTSTATION") + .unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/target/teststation").to_string()); + assert!( + std::path::Path::new(&binary).exists(), + "{binary} is missing: run scripts/build-teststation.sh first" + ); + let mut child = Command::new(&binary) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("the teststation starts"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + (child, stdin, stdout) +} + +/// One command to the teststation and its one-line answer. +fn ask(io: &Mutex<(ChildStdin, BufReader)>, command: &str) -> String { + let mut io = io.lock().unwrap(); + writeln!(io.0, "{command}").unwrap(); + io.0.flush().unwrap(); + let mut line = String::new(); + io.1.read_line(&mut line).unwrap(); + line.trim_end().to_string() +} + impl Drop for TestStations { fn drop(&mut self) { let _ = self.child.kill(); diff --git a/tests/pool.rs b/tests/pool.rs new file mode 100644 index 0000000..785ae77 --- /dev/null +++ b/tests/pool.rs @@ -0,0 +1,538 @@ +//! A node's pool of station links, against macula-go's in-process stations +//! (tests/common/lab.rs): one link per pinned seed, redialed when it drops +//! and given back its subscriptions and served procedures; calls and streams +//! that reach a provider at its own station, resolved from the DHT and +//! trusted only under the pinned realm key; and records through the pool. + +mod common; + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use common::lab::{Lab, LabRealm, LabStation}; +use macula_rust::cbor::Value; +use macula_rust::frame::StreamMode; +use macula_rust::node_key::{NodeKey, PUZZLE_DIFFICULTY}; +use macula_rust::pool::{Call, Offer, Opts, Pool, PoolError, Seed, StreamCall}; +use macula_rust::profile::Profile; +use macula_rust::record::{ + self, new_node_record, new_procedure_advertisement, new_station_endpoint, +}; +use macula_rust::record::{ + NodeRecordOptions, ProcedureAdvertisementOptions, RecordType, StationEndpointOptions, +}; +use macula_rust::station_link::{handler, stream_handler, LinkError, Publication, StreamEvent}; + +const ORG: &str = "mcl-echo"; +const PROCEDURE: &str = "mcl-echo/echo"; +const TOPIC: &str = "mcl-news/wire/news_item_reported_v1"; + +fn key(profile: Profile) -> Arc { + Arc::new(NodeKey::generate_identity(profile, PUZZLE_DIFFICULTY).unwrap()) +} + +fn id(key: &NodeKey) -> [u8; 32] { + key.node_id().unwrap() +} + +/// A pool as `key` on `seeds`, trusting `realm` (none when `None`). +async fn connect(key: &Arc, realm: Option<&LabRealm>, seeds: &[&LabStation]) -> Pool { + let mut opts = Opts::new(key.clone()); + opts.realm_trust = realm + .map(|r| HashMap::from([(r.id, r.key.clone())])) + .unwrap_or_default(); + opts.respawn_delay = Duration::from_millis(100); + opts.connect_timeout = Duration::from_secs(15); + Pool::connect(seeds.iter().map(|s| s.seed()).collect(), opts) + .await + .unwrap() +} + +fn echo() -> Offer { + Offer::unary( + [0; 32], + PROCEDURE, + handler(|r| async move { Ok(r.payload) }), + ) +} + +fn offer_in(realm: &LabRealm, o: Offer) -> Offer { + Offer { + realm: realm.id, + ..o + } +} + +async fn within(limit: Duration, what: &str, mut ok: impl FnMut() -> bool) { + let deadline = Instant::now() + limit; + while Instant::now() < deadline { + if ok() { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("not within {limit:?}: {what}"); +} + +async fn eventually(what: &str, ok: impl FnMut() -> bool) { + within(Duration::from_secs(10), what, ok).await; +} + +fn call(realm: [u8; 32], procedure: &str, payload: Value) -> Call { + Call { + realm, + procedure: procedure.to_string(), + payload, + ..Call::default() + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn connect_refuses_what_cannot_be_trusted() { + let lab = Lab::start(Profile::PqPure); + let s = lab.station("refusals"); + let key = key(Profile::PqPure); + let unpinned = Seed { + node_id: [0; 32], + ..s.seed() + }; + let refused = Pool::connect(vec![unpinned], Opts::new(key.clone())).await; + assert!( + matches!(refused, Err(PoolError::SeedNotPinned(_))), + "{refused:?}" + ); + + let mut bad = Opts::new(key.clone()); + bad.realm_trust = HashMap::from([([1; 32], b"not a key".to_vec())]); + let refused = Pool::connect(vec![s.seed()], bad).await; + assert!( + matches!(refused, Err(PoolError::RealmTrustInvalid(_))), + "{refused:?}" + ); + + let mut few = Opts::new(key.clone()); + few.max_seeds = 2; + let refused = Pool::connect(vec![s.seed(), s.seed(), s.seed()], few).await; + assert!( + matches!(refused, Err(PoolError::TooManySeeds { .. })), + "{refused:?}" + ); + + let refused = Pool::connect(vec![], Opts::new(key.clone())).await; + assert!(matches!(refused, Err(PoolError::NoSeeds)), "{refused:?}"); + + let mut too_wide = Opts::new(key); + too_wide.max_direct_links = 65; + let refused = Pool::connect(vec![s.seed()], too_wide).await; + assert!( + matches!(refused, Err(PoolError::InvalidOpts(_))), + "{refused:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_pool_links_every_seed_and_redials_a_dropped_one() { + let lab = Lab::start(Profile::PqPure); + let (a, b) = (lab.station("seed a"), lab.station("seed b")); + let k = key(Profile::PqPure); + let p = connect(&k, None, &[&a, &b]).await; + let me = p.node_id(); + eventually("both links up", || { + lab.connected(&a, &me) && lab.connected(&b, &me) + }) + .await; + lab.drop_node(&a, &me); + eventually("the dropped link redialed", || lab.connected(&a, &me)).await; + eventually("both links up in the status", || { + p.status().iter().filter(|l| l.up).count() == 2 + }) + .await; + p.close().await; + eventually("closed at both stations", || { + !lab.connected(&a, &me) && !lab.connected(&b, &me) + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_subscription_hears_once_and_survives_a_redial() { + let lab = Lab::start(Profile::PqPure); + let (a, b) = (lab.station("pubsub a"), lab.station("pubsub b")); + let (lk, pk) = (key(Profile::PqPure), key(Profile::PqPure)); + let listener = connect(&lk, None, &[&a, &b]).await; + let publisher = connect(&pk, None, &[&a, &b]).await; + let realm = [7; 32]; + let mut sub = listener.subscribe(&realm, TOPIC).await.unwrap(); + let me = listener.node_id(); + eventually("subscribed at both stations", || { + lab.subscribed(&a, &me, &realm, TOPIC) && lab.subscribed(&b, &me, &realm, TOPIC) + }) + .await; + + async fn heard( + publisher: &Pool, + sub: &mut macula_rust::pool::Subscription, + realm: [u8; 32], + text: &str, + ) -> usize { + publisher + .publish(Publication { + realm, + topic: TOPIC.to_string(), + payload: Value::text(text), + ttl_ms: None, + }) + .await + .unwrap(); + let mut n = 0; + let until = tokio::time::Instant::now() + Duration::from_secs(1); + while let Ok(Some(event)) = tokio::time::timeout_at(until, sub.recv()).await { + if event.payload == Value::text(text) { + n += 1; + } + } + n + } + + assert_eq!(heard(&publisher, &mut sub, realm, "first").await, 1); + lab.drop_node(&a, &me); + lab.drop_node(&b, &me); + eventually("the listener resubscribed at both stations", || { + lab.subscribed(&a, &me, &realm, TOPIC) && lab.subscribed(&b, &me, &realm, TOPIC) + }) + .await; + assert_eq!(heard(&publisher, &mut sub, realm, "after").await, 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_call_dials_the_provider_s_station() { + for profile in [Profile::PqPure, Profile::PqHybrid] { + let lab = Lab::start(profile); + let (serving, callers) = (lab.station("serving"), lab.station("callers")); + lab.share(&[&serving, &callers]); + let realm = lab.realm("direct", ORG); + let pk = key(profile); + lab.admit(&realm, &serving, &[id(&pk)]); + let provider = connect(&pk, Some(&realm), &[&serving]).await; + provider.serve(offer_in(&realm, echo())).await.unwrap(); + + let ck = key(profile); + let caller = connect(&ck, Some(&realm), &[&callers]).await; + let answered = caller + .call(call(realm.id, PROCEDURE, Value::text("hello"))) + .await + .unwrap(); + assert_eq!(answered, Value::text("hello")); + assert!( + lab.connected(&serving, &caller.node_id()), + "the caller dialed the serving station" + ); + let providers = caller.providers(&realm.id, PROCEDURE).await.unwrap(); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].node, id(&pk)); + assert_eq!(providers[0].station, serving.node_id); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_call_trusts_only_the_pinned_realm() { + let lab = Lab::start(Profile::PqPure); + let s = lab.station("trust"); + let realm = lab.realm("trusted", ORG); + let impostor = lab.impostor(&realm); + let pk = key(Profile::PqPure); + lab.admit(&impostor, &s, &[id(&pk)]); + let provider = connect(&pk, Some(&impostor), &[&s]).await; + provider.serve(offer_in(&impostor, echo())).await.unwrap(); + + let caller = connect(&key(Profile::PqPure), Some(&realm), &[&s]).await; + let mut c = call(realm.id, PROCEDURE, Value::Map(vec![])); + c.timeout = Duration::from_secs(2); + let untrusted = caller.call(c.clone()).await; + assert!( + matches!(untrusted, Err(PoolError::NoProvider(_))), + "{untrusted:?}" + ); + let unpinned = caller + .call(Call { + realm: [9; 32], + ..c + }) + .await; + assert!( + matches!(unpinned, Err(PoolError::NoRealmKey)), + "{unpinned:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_served_procedure_survives_a_redial_until_stopped() { + let lab = Lab::start(Profile::PqPure); + let s = lab.station("replay"); + let realm = lab.realm("replay", ORG); + let pk = key(Profile::PqPure); + lab.admit(&realm, &s, &[id(&pk)]); + let provider = connect(&pk, Some(&realm), &[&s]).await; + let served = provider.serve(offer_in(&realm, echo())).await.unwrap(); + eventually("advertised", || lab.advertised(&s, &realm.id, PROCEDURE)).await; + lab.drop_node(&s, &provider.node_id()); + eventually("advertised again after the redial", || { + lab.connected(&s, &provider.node_id()) && lab.advertised(&s, &realm.id, PROCEDURE) + }) + .await; + served.stop().await.unwrap(); + eventually("withdrawn", || !lab.advertised(&s, &realm.id, PROCEDURE)).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_call_tries_the_next_candidate() { + let lab = Lab::start(Profile::PqPure); + let (gone, live) = (lab.station("gone"), lab.station("live")); + lab.share(&[&gone, &live]); + let realm = lab.realm("candidates", ORG); + let (lost_key, live_key) = (key(Profile::PqPure), key(Profile::PqPure)); + lab.admit(&realm, &gone, &[id(&lost_key), id(&live_key)]); + let live_provider = connect(&live_key, Some(&realm), &[&live]).await; + live_provider + .serve(Offer::unary( + realm.id, + PROCEDURE, + handler(|_| async { Err("no".to_string()) }), + )) + .await + .unwrap(); + // The lost provider advertises last, so its candidate is the freshest and + // is tried first. + tokio::time::sleep(Duration::from_millis(5)).await; + let lost = connect(&lost_key, Some(&realm), &[&gone]).await; + lost.serve(offer_in(&realm, echo())).await.unwrap(); + + let caller = connect(&key(Profile::PqPure), Some(&realm), &[&live]).await; + let providers = caller.providers(&realm.id, PROCEDURE).await.unwrap(); + assert_eq!(providers.len(), 2); + assert_eq!(providers[0].node, id(&lost_key), "freshest first"); + lab.stop(&gone); + let mut c = call(realm.id, PROCEDURE, Value::Map(vec![])); + c.timeout = Duration::from_secs(5); + match caller.call(c).await { + Err(PoolError::Link(LinkError::Provider { + code, responded_by, .. + })) => { + assert_eq!(code, "handler_error"); + assert_eq!(responded_by, id(&live_key)); + } + other => panic!("{other:?}"), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_station_endpoint_must_be_the_station_s_own() { + let lab = Lab::start(Profile::PqPure); + let (liar, victim) = (lab.station("liar"), lab.station("victim")); + let p = connect(&key(Profile::PqPure), None, &[&liar]).await; + // An endpoint record for the liar's address, signed by a key that is not + // the victim's, stored in the victim's slot. + let forger = key(Profile::PqPure); + let forged = new_station_endpoint( + liar.port, + &StationEndpointOptions { + host_advertised: vec![liar.host.clone()], + ..StationEndpointOptions::default() + }, + ) + .unwrap(); + let wire = record::encode(&record::sign(&forged, &forger).unwrap()).unwrap(); + lab.forge(&liar, &record::station_endpoint_key(&victim.node_id), &wire); + let refused = p.station_target(&victim.node_id).await; + assert!( + matches!(refused, Err(PoolError::NoStationEndpoint(_))), + "{refused:?}" + ); + let own = p.station_target(&liar.node_id).await.unwrap(); + assert_eq!(own.expected_node_id, liar.node_id); + assert_eq!( + (own.host.as_str(), own.port), + (liar.host.as_str(), liar.port) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_unreached_direct_station_is_not_kept() { + let lab = Lab::start(Profile::PqPure); + let (s, gone) = (lab.station("keeps"), lab.station("never reached")); + lab.share(&[&s, &gone]); + lab.stop(&gone); + let p = connect(&key(Profile::PqPure), None, &[&s]).await; + let reached = tokio::time::timeout(Duration::from_secs(10), p.link_to(&gone.node_id)).await; + assert!( + matches!(reached, Ok(Err(_))), + "a stopped station was linked or never refused" + ); + assert!( + p.status().iter().all(|l| l.station != gone.node_id), + "the unreached station is still held: {:?}", + p.status() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn unsubscribe_ends_the_subscription_everywhere() { + let lab = Lab::start(Profile::PqPure); + let (a, b) = (lab.station("unsubscribe a"), lab.station("unsubscribe b")); + let p = connect(&key(Profile::PqPure), None, &[&a, &b]).await; + let me = p.node_id(); + eventually("both links up", || { + lab.connected(&a, &me) && lab.connected(&b, &me) + }) + .await; + let realm = [3; 32]; + let mut sub = p.subscribe(&realm, TOPIC).await.unwrap(); + eventually("subscribed at both stations", || { + lab.subscribed(&a, &me, &realm, TOPIC) && lab.subscribed(&b, &me, &realm, TOPIC) + }) + .await; + sub.unsubscribe().await.unwrap(); + assert_eq!(sub.recv().await, None, "the subscription's events end"); + eventually("unsubscribed at both stations", || { + !lab.subscribed(&a, &me, &realm, TOPIC) && !lab.subscribed(&b, &me, &realm, TOPIC) + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_record_put_through_the_pool_is_found_by_type() { + let lab = Lab::start(Profile::PqPure); + let s = lab.station("records"); + let k = key(Profile::PqPure); + let p = connect(&k, None, &[&s]).await; + let unsigned = new_node_record( + &p.node_id(), + &[], + 0, + &NodeRecordOptions { + display_name: "recorder".into(), + ..NodeRecordOptions::default() + }, + ) + .unwrap(); + let wire = record::encode(&record::sign(&unsigned, &k).unwrap()).unwrap(); + p.put_record(&wire).await.unwrap(); + let (found, dropped) = p + .find_records_by_type(RecordType::NODE_RECORD) + .await + .unwrap(); + assert_eq!(dropped, 0); + assert_eq!(found.len(), 1); + assert_eq!( + found[0].record().signed.as_ref().unwrap().key_id, + p.node_id() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_stream_dials_the_provider_s_station_and_is_released_promptly() { + let lab = Lab::start(Profile::PqPure); + let (serving, callers) = (lab.station("stream serving"), lab.station("stream callers")); + lab.share(&[&serving, &callers]); + let realm = lab.realm("streams", ORG); + let pk = key(Profile::PqPure); + lab.admit(&realm, &serving, &[id(&pk)]); + let provider = connect(&pk, Some(&realm), &[&serving]).await; + provider + .serve(Offer::stream( + realm.id, + PROCEDURE, + StreamMode::ServerStream, + stream_handler(|s| async move { + for chunk in ["a", "b"] { + s.send(chunk.as_bytes()).await.map_err(|e| e.to_string())?; + } + s.close().await.map_err(|e| e.to_string()) + }), + )) + .await + .unwrap(); + let caller = connect(&key(Profile::PqPure), Some(&realm), &[&callers]).await; + let stream = caller + .open_stream(StreamCall { + realm: realm.id, + procedure: PROCEDURE.to_string(), + mode: StreamMode::ServerStream, + ..StreamCall::default() + }) + .await + .unwrap(); + let mut got = Vec::new(); + loop { + match tokio::time::timeout(Duration::from_secs(5), stream.recv()) + .await + .unwrap() + .unwrap() + { + StreamEvent::Data { + body: Value::Bytes(b), + .. + } => got.push(b), + StreamEvent::End { .. } => break, + other => panic!("{other:?}"), + } + } + assert_eq!(got, vec![b"a".to_vec(), b"b".to_vec()]); + // macula-io/macula-rust#3: a stream ended on both sides is released at + // once, not after a timeout, without its handle being dropped. + within( + Duration::from_secs(2), + "every relayed stream released", + || lab.relayed(&serving) == 0 && lab.relayed(&callers) == 0, + ) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_procedure_in_the_node_s_own_namespace_needs_no_realm_key() { + let lab = Lab::start(Profile::PqPure); + let (serving, callers) = (lab.station("own serving"), lab.station("own callers")); + lab.share(&[&serving, &callers]); + let realm = [0x42; 32]; + let pk = key(Profile::PqPure); + let provider = connect(&pk, None, &[&serving]).await; + let ring = record::own_procedure(&provider.node_id(), "ring"); + provider + .serve(Offer::unary( + realm, + &ring, + handler(|r| async move { Ok(r.payload) }), + )) + .await + .unwrap(); + + // Another node's advertisement for the provider's namespace. + let mallory = key(Profile::PqPure); + let forged = new_procedure_advertisement( + &id(&mallory), + &realm, + &ring, + &callers.node_id, + &ProcedureAdvertisementOptions::default(), + ) + .unwrap(); + lab.put( + &callers, + &record::encode(&record::sign(&forged, &mallory).unwrap()).unwrap(), + ); + + let caller = connect(&key(Profile::PqPure), None, &[&callers]).await; + let answered = caller + .call(call(realm, &ring, Value::text("ring ring"))) + .await + .unwrap(); + assert_eq!(answered, Value::text("ring ring")); + let providers = caller.providers(&realm, &ring).await.unwrap(); + assert_eq!(providers.len(), 1, "the provider alone: {providers:?}"); + assert_eq!(providers[0].node, provider.node_id()); + let org = caller + .call(call(realm, PROCEDURE, Value::Map(vec![]))) + .await; + assert!(matches!(org, Err(PoolError::NoRealmKey)), "{org:?}"); +} diff --git a/tests/teststation/lab.go b/tests/teststation/lab.go new file mode 100644 index 0000000..6e0273a --- /dev/null +++ b/tests/teststation/lab.go @@ -0,0 +1,251 @@ +package main + +import ( + "bufio" + "encoding/hex" + "fmt" + "os" + "strconv" + "strings" + + "github.com/macula-io/macula-go/profile" + "github.com/macula-io/macula-go/teststation" +) + +// runLab starts stations and realms as commands ask, each named by its index +// in the order it was started, and answers each command with one line: +// +// start station +// stop stopped +// drop dropped +// connected yes | no +// advertised yes | no +// subscribed yes | no +// share ... shared +// put put +// forge forged +// relayed relayed +// realm realm +// impostor realm +// admit ... admitted +// +// An impostor of realm r has its realm_id and org and another realm key, as a +// node claiming the realm without its key would sign. +// +// A command it cannot follow is answered "error ". +func runLab(t *helperT, p profile.Profile) { + var stations []*teststation.Station + var realms []teststation.Realm + in := bufio.NewScanner(os.Stdin) + in.Buffer(make([]byte, 1<<20), 4<<20) + for in.Scan() { + fields := strings.Fields(in.Text()) + if len(fields) == 0 { + continue + } + station := func(i int) *teststation.Station { + n, err := strconv.Atoi(fields[i]) + if err != nil || n < 0 || n >= len(stations) { + return nil + } + return stations[n] + } + answer, err := lab(t, p, fields, station, &stations, &realms) + if err != nil { + fmt.Println("error", err) + continue + } + fmt.Println(answer) + } +} + +func lab(t *helperT, p profile.Profile, f []string, station func(int) *teststation.Station, + stations *[]*teststation.Station, realms *[]teststation.Realm) (string, error) { + need := func(n int) error { + if len(f) < n { + return fmt.Errorf("%s needs %d arguments", f[0], n-1) + } + return nil + } + pick := func(i int) (*teststation.Station, error) { + if err := need(i + 1); err != nil { + return nil, err + } + if s := station(i); s != nil { + return s, nil + } + return nil, fmt.Errorf("no station %s", f[i]) + } + switch f[0] { + case "start": + if err := need(2); err != nil { + return "", err + } + s := teststation.Start(t, p, strings.Join(f[1:], " ")) + *stations = append(*stations, s) + return fmt.Sprintf("station %d %s %d %s", len(*stations)-1, s.Host, s.Port, hex.EncodeToString(s.NodeID[:])), nil + case "stop": + s, err := pick(1) + if err != nil { + return "", err + } + s.Stop() + return "stopped", nil + case "drop", "connected": + s, err := pick(1) + if err != nil { + return "", err + } + node, err := id32(f, 2) + if err != nil { + return "", err + } + if f[0] == "drop" { + s.Drop(node) + return "dropped", nil + } + return yes(s.Connected(node)), nil + case "advertised": + s, err := pick(1) + if err != nil { + return "", err + } + realm, err := id32(f, 2) + if err != nil { + return "", err + } + if err := need(4); err != nil { + return "", err + } + return yes(s.Advertised(realm, f[3])), nil + case "subscribed": + s, err := pick(1) + if err != nil { + return "", err + } + node, err := id32(f, 2) + if err != nil { + return "", err + } + realm, err := id32(f, 3) + if err != nil { + return "", err + } + if err := need(5); err != nil { + return "", err + } + return yes(s.Subscribed(node, realm, f[4])), nil + case "share": + var shared []*teststation.Station + for i := 1; i < len(f); i++ { + s, err := pick(i) + if err != nil { + return "", err + } + shared = append(shared, s) + } + teststation.ShareDHT(shared...) + return "shared", nil + case "put": + s, err := pick(1) + if err != nil { + return "", err + } + wire, err := bytesAt(f, 2) + if err != nil { + return "", err + } + s.Put(wire) + return "put", nil + case "forge": + s, err := pick(1) + if err != nil { + return "", err + } + key, err := id32(f, 2) + if err != nil { + return "", err + } + wire, err := bytesAt(f, 3) + if err != nil { + return "", err + } + s.Forge(key, wire) + return "forged", nil + case "relayed": + s, err := pick(1) + if err != nil { + return "", err + } + return fmt.Sprintf("relayed %d", s.Relayed()), nil + case "realm": + if err := need(3); err != nil { + return "", err + } + r := teststation.NewRealm(t, p, f[1], f[2]) + *realms = append(*realms, r) + return fmt.Sprintf("realm %d %s %s", len(*realms)-1, hex.EncodeToString(r.ID[:]), hex.EncodeToString(r.RealmKey())), nil + case "impostor": + if err := need(2); err != nil { + return "", err + } + n, err := strconv.Atoi(f[1]) + if err != nil || n < 0 || n >= len(*realms) { + return "", fmt.Errorf("no realm %s", f[1]) + } + r := (*realms)[n] + r.Key = teststation.Key(t, p, fmt.Sprintf("impostor of realm %d", n)) + *realms = append(*realms, r) + return fmt.Sprintf("realm %d %s %s", len(*realms)-1, hex.EncodeToString(r.ID[:]), hex.EncodeToString(r.RealmKey())), nil + case "admit": + if err := need(4); err != nil { + return "", err + } + n, err := strconv.Atoi(f[1]) + if err != nil || n < 0 || n >= len(*realms) { + return "", fmt.Errorf("no realm %s", f[1]) + } + s, err := pick(2) + if err != nil { + return "", err + } + var nodes [][32]byte + for i := 3; i < len(f); i++ { + node, err := id32(f, i) + if err != nil { + return "", err + } + nodes = append(nodes, node) + } + (*realms)[n].Admit(t, s, nodes...) + return "admitted", nil + } + return "", fmt.Errorf("unknown command %s", f[0]) +} + +func yes(b bool) string { + if b { + return "yes" + } + return "no" +} + +func bytesAt(f []string, i int) ([]byte, error) { + if len(f) <= i { + return nil, fmt.Errorf("%s needs argument %d", f[0], i) + } + return hex.DecodeString(f[i]) +} + +func id32(f []string, i int) ([32]byte, error) { + var out [32]byte + raw, err := bytesAt(f, i) + if err != nil { + return out, err + } + if len(raw) != 32 { + return out, fmt.Errorf("argument %d is not 32 bytes", i) + } + copy(out[:], raw) + return out, nil +} diff --git a/tests/teststation/main.go b/tests/teststation/main.go index e86fdca..cb5986e 100644 --- a/tests/teststation/main.go +++ b/tests/teststation/main.go @@ -1,12 +1,18 @@ -// Command teststation runs in-process macula 12 stations for the PHP -// tests: two stations sharing one DHT, and a test realm with one org. It -// prints one JSON line, {stations: [{host, port, node_id}], realm_id, +// Command teststation runs in-process macula 12 stations for the Rust +// tests, in one of two modes. +// +// By default: two stations sharing one DHT, and a test realm with one org. +// It prints one JSON line, {stations: [{host, port, node_id}], realm_id, // realm_key, org}, then reads commands on stdin until it closes: // // admit the org delegates its procedures to that node // relayed how many streams the stations relay now // -// answering each with one line. It exits when stdin closes. +// With a second argument "lab": no stations yet, and the commands in lab.go, +// which start and shape stations and realms one by one. +// +// Either mode answers each command with one line, and exits when stdin +// closes. package main import ( @@ -52,6 +58,10 @@ func main() { } p = parsed } + if len(os.Args) > 2 && os.Args[2] == "lab" { + runLab(t, p) + return + } stations := []*teststation.Station{teststation.Start(t, p, "rust a"), teststation.Start(t, p, "rust b")} teststation.ShareDHT(stations...) realm := teststation.NewRealm(t, p, "macula-rust tests", "mcl-rust") From 85d0a7921abb41cd008d3fc29a34924a0311009d Mon Sep 17 00:00:00 2001 From: beamologist Date: Sat, 26 Sep 2026 09:39:44 +0200 Subject: [PATCH 10/12] live: one run against a macula 12 station, ignored unless asked Mirrors macula-php's FleetTest: a pool pinned to one station as a pq_hybrid key made for the run, the DHT read, mcl-echo/echo called by direct dial, and its own publication heard. Passed 3/3 against helsinki at 2026-09-26T07:37:49Z. Co-Authored-By: Claude Opus 5.5 --- tests/live.rs | 131 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tests/live.rs diff --git a/tests/live.rs b/tests/live.rs new file mode 100644 index 0000000..be07b59 --- /dev/null +++ b/tests/live.rs @@ -0,0 +1,131 @@ +//! A live run against one macula 12 station: the pool connects pinned with a +//! key generated for the run and never saved, reads the DHT, calls +//! mcl-echo/echo by direct dial, and hears its own publication. The tests +//! are ignored by default and run with `cargo test --test live -- --ignored`; +//! they need every one of MACULA_RUST_LIVE_SEED (host:port, [v6]:port for +//! IPv6), MACULA_RUST_LIVE_STATION_ID, MACULA_RUST_LIVE_REALM and +//! MACULA_RUST_LIVE_REALM_KEY (hex), and an unset one fails the run, naming +//! it. They put nothing in the DHT, and publish once. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use macula_rust::cbor::Value; +use macula_rust::node_key::{NodeKey, PUZZLE_DIFFICULTY}; +use macula_rust::pool::{Call, Opts, Pool, Seed}; +use macula_rust::profile::Profile; +use macula_rust::record::RecordType; +use macula_rust::station_link::Publication; + +const VARIABLES: [&str; 4] = [ + "MACULA_RUST_LIVE_SEED", + "MACULA_RUST_LIVE_STATION_ID", + "MACULA_RUST_LIVE_REALM", + "MACULA_RUST_LIVE_REALM_KEY", +]; + +fn var(name: &str) -> String { + std::env::var(name).unwrap_or_default() +} + +fn hex32(name: &str) -> [u8; 32] { + hex::decode(var(name)) + .ok() + .and_then(|b| b.try_into().ok()) + .unwrap_or_else(|| panic!("{name} must be 64 hex characters")) +} + +/// A pool on the live seed as a pq_hybrid key made for this run, and the +/// realm it trusts. +async fn live() -> (Pool, [u8; 32]) { + let missing: Vec<&str> = VARIABLES + .iter() + .copied() + .filter(|v| var(v).is_empty()) + .collect(); + assert!(missing.is_empty(), "live tests need {}", missing.join(", ")); + let seed = var("MACULA_RUST_LIVE_SEED"); + let (host, port) = seed + .rsplit_once(':') + .unwrap_or_else(|| panic!("MACULA_RUST_LIVE_SEED must be host:port, got {seed}")); + let host = host + .trim_start_matches('[') + .trim_end_matches(']') + .to_string(); + let realm = hex32("MACULA_RUST_LIVE_REALM"); + let key = Arc::new(NodeKey::generate_identity(Profile::PqHybrid, PUZZLE_DIFFICULTY).unwrap()); + let mut opts = Opts::new(key); + opts.realm_trust = HashMap::from([( + realm, + hex::decode(var("MACULA_RUST_LIVE_REALM_KEY")).unwrap(), + )]); + opts.connect_timeout = Duration::from_secs(60); + let pool = Pool::connect( + vec![Seed { + host, + port: port.parse().expect("the seed's port"), + node_id: hex32("MACULA_RUST_LIVE_STATION_ID"), + }], + opts, + ) + .await + .unwrap(); + (pool, realm) +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "live: needs MACULA_RUST_LIVE_* and a macula 12 station"] +async fn the_station_holds_verified_node_records() { + let (pool, _) = live().await; + let (records, _) = pool + .find_records_by_type(RecordType::NODE_RECORD) + .await + .unwrap(); + assert!(!records.is_empty()); + pool.close().await; +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "live: needs MACULA_RUST_LIVE_* and a macula 12 station"] +async fn mcl_echo_is_reached_by_direct_dial() { + let (pool, realm) = live().await; + let answered = pool + .call(Call { + realm, + procedure: "mcl-echo/echo".into(), + payload: Value::text("hello"), + timeout: Duration::from_secs(15), + ..Call::default() + }) + .await + .unwrap(); + assert_eq!(answered, Value::text("hello")); + pool.close().await; +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "live: needs MACULA_RUST_LIVE_* and a macula 12 station"] +async fn the_pool_hears_its_own_publication() { + let (pool, realm) = live().await; + let mut suffix = [0u8; 8]; + aws_lc_rs::rand::fill(&mut suffix).unwrap(); + let topic = format!( + "mcl-rust/live/check/publication_heard_v1/{}", + hex::encode(suffix) + ); + let mut sub = pool.subscribe(&realm, &topic).await.unwrap(); + tokio::time::sleep(Duration::from_millis(300)).await; + pool.publish(Publication { + realm, + topic, + payload: Value::text("heard"), + ttl_ms: None, + }) + .await + .unwrap(); + let event = tokio::time::timeout(Duration::from_secs(10), sub.recv()).await; + let _ = sub.unsubscribe().await; + pool.close().await; + assert_eq!(event.unwrap().unwrap().payload, Value::text("heard")); +} From da9e7b0c1f3a150ae3e04b675a21e3582119f481 Mon Sep 17 00:00:00 2001 From: beamologist Date: Sat, 26 Sep 2026 09:54:04 +0200 Subject: [PATCH 11/12] ffi: the mobile bindings over the macula 12 pool macula-rust-ffi rewritten on the new core API; the 10.x surface (sessions, direct dial, cert chains, UCAN, content) and its live tests are gone. - FfiNodeKey: an identity key in either profile with the puzzle solved, kept in an owner-only key file or the platform's secure store. - FfiPool: pinned seeds, realm trust and the pool's options; calls, providers, DHT records. - Serving with a handler Kotlin or Swift implements (FfiCallHandler, FfiStreamHandler), an own-namespace helper, FfiServed. - FfiSubscription (next with a timeout), publish. - FfiStream on either side. - FfiValue kept as it was (Items/Fields, i64). - FfiError maps the pool's and link's errors, plus a foreign handler's unexpected throw. Tests: macula-rust-ffi/tests/pool_ffi.rs drives the FFI types against the teststation lab, which now finds the helper in the workspace's target/. Kotlin and Swift bindings generate; neither is compiled here (no Kotlin or Swift toolchain on this box). Co-Authored-By: Claude Opus 5.5 --- macula-rust-ffi/Cargo.toml | 11 +- macula-rust-ffi/src/lib.rs | 1913 ++--------------- macula-rust-ffi/src/node_key.rs | 130 ++ macula-rust-ffi/src/pool.rs | 237 ++ macula-rust-ffi/src/pubsub.rs | 117 + macula-rust-ffi/src/serve.rs | 121 ++ macula-rust-ffi/src/stream.rs | 181 ++ .../tests/live_cert_chain_direct_dial.rs | 309 --- macula-rust-ffi/tests/live_ffi.rs | 758 ------- macula-rust-ffi/tests/pool_ffi.rs | 360 ++++ tests/common/lab.rs | 4 + tests/common/mod.rs | 14 +- 12 files changed, 1317 insertions(+), 2838 deletions(-) create mode 100644 macula-rust-ffi/src/node_key.rs create mode 100644 macula-rust-ffi/src/pool.rs create mode 100644 macula-rust-ffi/src/pubsub.rs create mode 100644 macula-rust-ffi/src/serve.rs create mode 100644 macula-rust-ffi/src/stream.rs delete mode 100644 macula-rust-ffi/tests/live_cert_chain_direct_dial.rs delete mode 100644 macula-rust-ffi/tests/live_ffi.rs create mode 100644 macula-rust-ffi/tests/pool_ffi.rs diff --git a/macula-rust-ffi/Cargo.toml b/macula-rust-ffi/Cargo.toml index 53b9a4e..9f6eff8 100644 --- a/macula-rust-ffi/Cargo.toml +++ b/macula-rust-ffi/Cargo.toml @@ -34,9 +34,8 @@ thiserror = "2" async-trait = "0.1" [dev-dependencies] -# tests/live_cert_chain_direct_dial.rs's self-issued realm CA/leaf fixture, -# mirroring ../tests/live_cert_chain.rs's own — versions matched to the -# core crate's own pins. -rcgen = { version = "0.14", default-features = false, features = ["pem", "ring", "x509-parser"] } -time = "0.3" -base64 = "0.23" +# tests/pool_ffi.rs drives the core crate's teststation lab +# (../tests/common), which reads the helper's JSON and hex. +hex = "0.4" +serde_json = "1" +tempfile = "3" diff --git a/macula-rust-ffi/src/lib.rs b/macula-rust-ffi/src/lib.rs index a395e86..e664fbe 100644 --- a/macula-rust-ffi/src/lib.rs +++ b/macula-rust-ffi/src/lib.rs @@ -1,55 +1,25 @@ -//! UniFFI (Kotlin/Swift) bindings for [`macula_rust`]. A thin wrapper, -//! not a reimplementation — everything here delegates straight to the -//! core crate; nothing wire-level lives in this crate at all. +//! UniFFI (Kotlin/Swift) bindings for [`macula_rust`] on the macula 12 wire. +//! A thin wrapper, not a reimplementation: everything here delegates to the +//! core crate's [`macula_rust::pool`], and nothing wire-level lives in this +//! crate. A separate crate keeps the core free of any UniFFI dependency or +//! FFI-shaped type, so it stays as usable from plain Rust or a CLI. //! -//! Structure mirrors `iroh-ffi`'s relationship to `iroh`: a separate -//! crate depending on the core one, so the core crate carries zero -//! UniFFI dependency and zero FFI-shaped types. That separation is what -//! keeps `macula-rust` itself just as usable from plain Rust, a CLI, -//! or WASM as it was before this crate existed. +//! What is wrapped: a node key ([`FfiNodeKey`]: generated in either +//! profile, kept in a key file or the platform's secure store), and a pool +//! of station links ([`FfiPool`]) with everything a node does through it: +//! calls to a provider at its own station, serving a procedure with a +//! handler the foreign side implements ([`FfiCallHandler`]), pubsub +//! ([`FfiSubscription`]), streaming sessions on either side ([`FfiStream`], +//! [`FfiStreamHandler`]), and DHT records. //! -//! Every application primitive the core crate has is wrapped: identity, -//! CONNECT/HELLO (either [`FfiTrust::Pinned`] or [`FfiTrust::WebPki`] — -//! see that type's own doc for when each applies), CALL/RESULT/ERROR as -//! both caller AND provider (`call`/[`FfiSession::serve_one_call`]), -//! UCAN-gated serving ([`FfiSession::serve_one_call_gated`]/ -//! [`FfiSession::call_with_ucan`]) and the standalone `ucan_*` mint/verify/ -//! introspect functions, PUBLISH/SUBSCRIBE/EVENT (including the supervised -//! [`FfiSession::run_publisher`]), content transfer, streaming RPC — both -//! the caller/consumer role (§13.1) and the provider role (§13.2/§6.9, -//! `advertise`/`accept_stream`), direct-dial resolution -//! ([`FfiSession::resolve_direct`]/[`call_direct`](FfiSession::call_direct)/ -//! [`advertise_direct`](FfiSession::advertise_direct)) and its cert-chain- -//! authorized variants (`*_with_cert_chain`), and direct-dial streaming/ -//! content transfer ([`FfiSession::open_stream_direct`]/ -//! [`FfiSession::put_direct`]/[`FfiSession::get_direct`]). +//! [`FfiValue`] mirrors every variant [`macula_rust::cbor::Value`] has, +//! narrowed only where the FFI boundary forces it: `Int` is `i64`, and an +//! integer outside it is [`FfiError::UnrepresentableValue`], never +//! truncated. Every 32-byte id crosses as bytes and is checked here +//! ([`FfiError::WrongByteLength`]). //! -//! Not exposed, each a real, reasoned decision rather than an oversight: -//! `Trust::Insecure` — see [`FfiTrust`]'s own doc; the core crate's -//! `keep_advertised`/`keep_advertised_direct` background-loop helpers — -//! see [`FfiSession::advertise_direct`]'s own doc for why a native -//! background timer is the wrong shape for a mobile app and what to do -//! instead; `Session::run_subscriber` — same reasoning as -//! `keep_advertised` (it takes a generic `stop: impl Future` and -//! `handler: impl FnMut`, neither of which crosses the UniFFI boundary, -//! and a native long-lived receive loop fights mobile app-lifecycle -//! management the same way a background timer does) — its full external -//! behavior (subscribe once, receive until stopped, unsubscribe when done) -//! is still achievable on the foreign side with [`FfiSession::subscribe`], -//! [`FfiSubscription::recv_event`] in a loop that carries on past a -//! timeout, and [`FfiSubscription::close`] — nothing is lost, only where -//! that loop lives. -//! -//! [`FfiValue`] mirrors every variant [`macula_rust::cbor::Value`] -//! has, including recursive list/map shapes (`Items`/`Fields`, via -//! `Vec` — see the type's own doc for why they aren't named `List`/ -//! `Map` like the core type), narrowed only where the FFI boundary -//! forces it: `Int` is `i64` not `i128` (out-of-range values round-trip -//! as an [`FfiError::UnrepresentableValue`] rather than silently -//! truncating). -//! -//! Generate the bindings with the `uniffi-bindgen` binary this crate -//! also builds, e.g.: +//! Generate the bindings with the `uniffi-bindgen` binary this crate also +//! builds, e.g.: //! ```text //! cargo build -p macula-rust-ffi --release //! cargo run -p macula-rust-ffi --bin uniffi-bindgen -- generate \ @@ -57,71 +27,151 @@ //! --language kotlin --out-dir bindings/kotlin //! ``` -uniffi::setup_scaffolding!(); +mod node_key; +mod pool; +mod pubsub; +mod serve; +mod stream; -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock after epoch") - .as_millis() as u64 -} +pub use node_key::{FfiNodeKey, FfiProfile}; +pub use pool::{ + own_procedure, FfiLinkStatus, FfiPool, FfiPoolOptions, FfiProvider, FfiRealmKey, FfiRecord, + FfiSeed, +}; +pub use pubsub::{FfiEvent, FfiSubscription}; +pub use serve::{FfiCallHandler, FfiRequest, FfiServed}; +pub use stream::{FfiStream, FfiStreamEncoding, FfiStreamEvent, FfiStreamHandler, FfiStreamMode}; + +use macula_rust::pool::PoolError; +use macula_rust::station_link::LinkError; -#[derive(Debug, thiserror::Error, uniffi::Error)] +uniffi::setup_scaffolding!(); + +/// Why an operation failed, as Kotlin and Swift see it. +#[derive(Debug, Clone, PartialEq, thiserror::Error, uniffi::Error)] pub enum FfiError { - #[error("connecting to the station: {reason}")] - Connect { reason: String }, - #[error("the call failed: {reason}")] - Call { reason: String }, - #[error("sending a frame failed: {reason}")] - Send { reason: String }, - #[error("receiving failed: {reason}")] - Recv { reason: String }, - #[error("content operation failed: {reason}")] - Content { reason: String }, - #[error("a value could not cross the FFI boundary: {reason}")] - UnrepresentableValue { reason: String }, + /// An argument outside what the operation takes. + #[error("invalid argument: {message}")] + InvalidArgument { message: String }, + /// A byte string of the wrong length, where a 32-byte id belongs. #[error("expected exactly {expected} bytes, got {actual}")] WrongByteLength { expected: u32, actual: u32 }, - #[error("this session is already closed")] - Closed, - #[error("the call handler failed: {reason}")] - CallHandlerFailed { reason: String }, - #[error("direct-dial resolution failed: {reason}")] - Resolve { reason: String }, - #[error("direct-dial trust violation: the dialed peer's proven identity did not match the resolved station (see resolved/dialed fields)")] - DirectDialTrustViolation { resolved: Vec, dialed: Vec }, - #[error("UCAN operation failed: {reason}")] - Ucan { reason: String }, - #[error("no seed is stored under this keystore identity")] + /// A value the FFI boundary cannot carry, such as an integer outside i64. + #[error("a value could not cross the FFI boundary: {message}")] + UnrepresentableValue { message: String }, + /// A node key that could not be made, saved or loaded. + #[error("node key: {message}")] + Key { message: String }, + /// Nothing is stored under this keystore identity. + #[error("no key is stored under this keystore identity")] KeystoreNotFound, - #[error("platform secure store error: {reason}")] - Keystore { reason: String }, + /// The platform's secure store failed. + #[error("platform secure store: {message}")] + Keystore { message: String }, + /// No station link came up, or none is up to carry the operation. + #[error("no station link: {message}")] + NoLink { message: String }, + /// A realm the pool pins no key for: nothing in it is served or trusted. + #[error("no realm key is pinned for the realm")] + NoRealmKey, + /// No trusted provider advertises or answered the procedure. + #[error("{message}")] + NoProvider { message: String }, + /// The provider's own ERROR: its code, detail, and who responded. + #[error("the provider answered {code}")] + Provider { + code: String, + detail: Option, + responded_by: Vec, + }, + /// The connected station could not relay the call. + #[error("the station could not relay the call: {code}")] + Relay { code: String }, + /// No answer within the timeout. + #[error("timed out")] + Timeout, + /// A record the DHT does not hold. + #[error("record not found")] + RecordNotFound, + /// A stream ended by an error: the peer's, the station's, or this side's. + #[error("stream error {code}: {message}")] + Stream { code: String, message: String }, + /// A stream that ended normally. + #[error("end of stream")] + EndOfStream, + /// A handler the foreign side implements refused, or failed. + #[error("handler: {message}")] + Handler { message: String }, + /// An operation on a closed pool, subscription, stream or serving. + #[error("closed")] + Closed, + /// Anything else the core crate reports, as its text. + #[error("{message}")] + Other { message: String }, +} + +impl From for FfiError { + /// A foreign handler that threw something other than an FfiError. + fn from(e: uniffi::UnexpectedUniFFICallbackError) -> Self { + FfiError::Handler { message: e.reason } + } } -impl From for FfiError { - fn from(e: macula_rust::ucan::UcanError) -> Self { - FfiError::Ucan { - reason: e.to_string(), +impl From for FfiError { + fn from(e: LinkError) -> Self { + match e { + LinkError::Provider { + responded_by, + code, + detail, + } => FfiError::Provider { + code, + detail, + responded_by: responded_by.to_vec(), + }, + LinkError::Relay { code, .. } => FfiError::Relay { code }, + LinkError::CallTimeout | LinkError::HandshakeTimeout => FfiError::Timeout, + LinkError::RecordNotFound => FfiError::RecordNotFound, + LinkError::Stream { code, message, .. } => FfiError::Stream { code, message }, + LinkError::EndOfStream => FfiError::EndOfStream, + LinkError::Closed | LinkError::StreamClosed | LinkError::Stopped => FfiError::Closed, + other => FfiError::Other { + message: other.to_string(), + }, } } } -impl From for FfiError { - fn from(e: macula_rust::keystore::KeyStoreError) -> Self { +impl From for FfiError { + fn from(e: PoolError) -> Self { match e { - macula_rust::keystore::KeyStoreError::NotFound => FfiError::KeystoreNotFound, - other => FfiError::Keystore { - reason: other.to_string(), + PoolError::Link(link) => link.into(), + PoolError::NoRealmKey => FfiError::NoRealmKey, + PoolError::Closed => FfiError::Closed, + e @ PoolError::NoProvider(_) => FfiError::NoProvider { + message: e.to_string(), + }, + e @ PoolError::NoLink(_) => FfiError::NoLink { + message: e.to_string(), + }, + e @ (PoolError::NoSeeds + | PoolError::SeedNotPinned(_) + | PoolError::TooManySeeds { .. } + | PoolError::RealmTrustInvalid(_) + | PoolError::InvalidOpts(_)) => FfiError::InvalidArgument { + message: e.to_string(), + }, + other => FfiError::Other { + message: other.to_string(), }, } } } -/// `Vec` -> `[u8; 32]`, with both lengths actually reported on -/// mismatch — UniFFI has no fixed-size byte array type, so every 32-byte -/// field (`realm`, node ids) crosses the boundary as `Vec` and gets -/// validated here. -fn to_32(bytes: Vec) -> Result<[u8; 32], FfiError> { +/// `Vec` to `[u8; 32]`, reporting both lengths on a mismatch: UniFFI has +/// no fixed-size byte array, so every id crosses as bytes and is checked +/// here. +pub(crate) fn to_32(bytes: Vec) -> Result<[u8; 32], FfiError> { let actual = bytes.len() as u32; bytes.try_into().map_err(|_| FfiError::WrongByteLength { expected: 32, @@ -129,15 +179,9 @@ fn to_32(bytes: Vec) -> Result<[u8; 32], FfiError> { }) } -/// `Vec` -> `[u8; 34]` — same as [`to_32`], for an MCID -/// (`<>`, `plans/PLAN_WIRE_PROTOCOL.md` -/// §12.1). -fn to_mcid(bytes: Vec) -> Result { - let actual = bytes.len() as u32; - bytes.try_into().map_err(|_| FfiError::WrongByteLength { - expected: 34, - actual, - }) +/// A timeout in milliseconds, zero for the core crate's default. +pub(crate) fn millis(ms: u64) -> std::time::Duration { + std::time::Duration::from_millis(ms) } /// A mirror of [`macula_rust::cbor::Value`], narrowed only where the @@ -220,7 +264,7 @@ impl TryFrom for FfiValue { i64::try_from(n) .map(FfiValue::Int) .map_err(|_| FfiError::UnrepresentableValue { - reason: format!("integer {n} is outside i64 range"), + message: format!("integer {n} is outside i64 range"), }) } Value::Bytes(b) => Ok(FfiValue::Bytes(b)), @@ -244,1660 +288,3 @@ impl TryFrom for FfiValue { } } } - -/// The result of a CALL: a mirror of -/// [`macula_rust::frame::CallResponse`]. -#[derive(uniffi::Enum, Debug, Clone)] -pub enum FfiCallResponse { - Result { - payload: FfiValue, - responded_by: Vec, - }, - Error { - code: u8, - name: String, - reported_by: Vec, - detail: Option, - }, -} - -impl TryFrom for FfiCallResponse { - type Error = FfiError; - - fn try_from(r: macula_rust::frame::CallResponse) -> Result { - use macula_rust::frame::CallResponse; - match r { - CallResponse::Result { - payload, - responded_by, - } => Ok(FfiCallResponse::Result { - payload: FfiValue::try_from(payload)?, - responded_by: responded_by.to_vec(), - }), - CallResponse::Error { - code, - name, - reported_by, - detail, - } => Ok(FfiCallResponse::Error { - code, - name, - reported_by: reported_by.to_vec(), - detail, - }), - } - } -} - -/// A resolved direct-dial target — a mirror of -/// [`macula_rust::direct_dial::Resolved`]: the station's own node id -/// (32 bytes) plus its dialable host/port. Returned by -/// [`FfiSession::resolve_direct`]; [`FfiSession::call_direct`] does this -/// same resolution internally, so most callers never need this type -/// directly — it's exposed for a caller that wants to resolve once and -/// decide what to do with the target itself (e.g. displaying it, or -/// dialing via a mechanism this crate doesn't cover). -#[derive(uniffi::Record, Debug, Clone)] -pub struct FfiResolved { - pub station: Vec, - pub host: String, - pub port: u16, -} - -impl From for FfiResolved { - fn from(r: macula_rust::direct_dial::Resolved) -> Self { - FfiResolved { - station: r.station.to_vec(), - host: r.host, - port: r.port, - } - } -} - -impl From for FfiError { - fn from(e: macula_rust::direct_dial::ResolveError) -> Self { - FfiError::Resolve { - reason: e.to_string(), - } - } -} - -impl From for FfiError { - fn from(e: macula_rust::direct_dial::CallError) -> Self { - use macula_rust::direct_dial::CallError; - match e { - CallError::Resolve(re) => re.into(), - CallError::TrustViolation { resolved, dialed } => FfiError::DirectDialTrustViolation { - resolved: resolved.to_vec(), - dialed: dialed.to_vec(), - }, - other => FfiError::Call { - reason: other.to_string(), - }, - } - } -} - -impl From for FfiError { - fn from(e: macula_rust::direct_dial::AdvertiseDirectError) -> Self { - FfiError::Send { - reason: e.to_string(), - } - } -} - -/// One entry in a UCAN token's capability list — mirrors -/// [`macula_rust::ucan::Capability`]. -#[derive(uniffi::Record, Debug, Clone, PartialEq)] -pub struct FfiCapability { - pub with: String, - pub can: String, -} - -impl From for FfiCapability { - fn from(c: macula_rust::ucan::Capability) -> Self { - FfiCapability { - with: c.with, - can: c.can, - } - } -} - -impl From for macula_rust::ucan::Capability { - fn from(c: FfiCapability) -> Self { - macula_rust::ucan::Capability { - with: c.with, - can: c.can, - } - } -} - -/// A UCAN token's decoded claims — a mirror of -/// [`macula_rust::ucan::Payload`], minus `facts`: the core type's -/// `facts` field is an arbitrary `serde_json::Value` map, which has no -/// UniFFI-representable shape (unlike [`FfiValue`], which exists -/// specifically to give CBOR values one) — the same class of narrowing -/// [`FfiValue::Int`] already documents for `i128`. A caller needing the -/// raw `fct` claim can decode the token bytes on the foreign side with any -/// JSON library. -#[derive(uniffi::Record, Debug, Clone)] -pub struct FfiUcanPayload { - pub issuer: String, - pub audience: String, - pub capabilities: Vec, - pub expires_at: Option, - pub not_before: Option, - pub nonce: String, - pub proofs: Vec, -} - -impl From for FfiUcanPayload { - fn from(p: macula_rust::ucan::Payload) -> Self { - FfiUcanPayload { - issuer: p.issuer, - audience: p.audience, - capabilities: p.capabilities.into_iter().map(Into::into).collect(), - expires_at: p.expires_at, - not_before: p.not_before, - nonce: p.nonce, - proofs: p.proofs, - } - } -} - -/// Mints a new UCAN token, self-issued and signed by `identity` — see -/// [`macula_rust::ucan::create`]'s own doc for the full contract -/// (`issuer`/`audience` are opaque strings, not validated here). A token -/// for a UCAN-gated procedure must name the calling node as its `audience`: -/// that node's id as lowercase hex. -#[uniffi::export] -pub fn ucan_create( - issuer: String, - audience: String, - capabilities: Vec, - identity: &FfiKeyPair, - expires_at: Option, - not_before: Option, -) -> Result, FfiError> { - let opts = macula_rust::ucan::CreateOpts { - expires_at, - not_before, - ..Default::default() - }; - macula_rust::ucan::create( - &issuer, - &audience, - capabilities.into_iter().map(Into::into).collect(), - &identity.0, - opts, - ) - .map_err(FfiError::from) -} - -/// Verifies `token`'s signature against `public_key` (32 bytes) and its -/// `exp`/`nbf` claims against the current time — see -/// [`macula_rust::ucan::verify`]'s own doc, including its check order. -/// Only a successful [`ucan_verify`] result should ever back an -/// authorization decision — [`ucan_decode`] and the `ucan_get_*` getters -/// below never check the signature. -#[uniffi::export] -pub fn ucan_verify(token: Vec, public_key: Vec) -> Result { - let key = to_32(public_key)?; - macula_rust::ucan::verify(&token, &key) - .map(FfiUcanPayload::from) - .map_err(FfiError::from) -} - -/// Parses `token`'s payload WITHOUT verifying its signature or checking -/// expiration — see [`ucan_verify`]'s doc for why that distinction matters. -#[uniffi::export] -pub fn ucan_decode(token: Vec) -> Result { - macula_rust::ucan::decode(&token) - .map(FfiUcanPayload::from) - .map_err(FfiError::from) -} - -/// `token`'s `iss` claim, unverified — see [`ucan_verify`]'s doc. -#[uniffi::export] -pub fn ucan_get_issuer(token: Vec) -> Result { - macula_rust::ucan::get_issuer(&token).map_err(FfiError::from) -} - -/// `token`'s `aud` claim, unverified — see [`ucan_verify`]'s doc. -#[uniffi::export] -pub fn ucan_get_audience(token: Vec) -> Result { - macula_rust::ucan::get_audience(&token).map_err(FfiError::from) -} - -/// `token`'s `cap` claim, unverified — see [`ucan_verify`]'s doc. -#[uniffi::export] -pub fn ucan_get_capabilities(token: Vec) -> Result, FfiError> { - macula_rust::ucan::get_capabilities(&token) - .map(|caps| caps.into_iter().map(Into::into).collect()) - .map_err(FfiError::from) -} - -/// `token`'s `exp` claim, unverified — see [`ucan_verify`]'s doc. -#[uniffi::export] -pub fn ucan_get_expiration(token: Vec) -> Result, FfiError> { - macula_rust::ucan::get_expiration(&token).map_err(FfiError::from) -} - -/// `token`'s `prf` claim, unverified — see [`ucan_verify`]'s doc. -#[uniffi::export] -pub fn ucan_get_proofs(token: Vec) -> Result, FfiError> { - macula_rust::ucan::get_proofs(&token).map_err(FfiError::from) -} - -/// Whether `token`'s `exp` claim is in the past, unverified — see -/// [`ucan_verify`]'s doc. A token with no `exp` claim is never expired. -#[uniffi::export] -pub fn ucan_is_expired(token: Vec) -> Result { - macula_rust::ucan::is_expired(&token).map_err(FfiError::from) -} - -/// `token`'s content identifier (SHA-256, base64url-no-pad) — used only -/// for proof-chain references between UCANs. See -/// [`macula_rust::ucan::compute_cid`]'s own doc. -#[uniffi::export] -pub fn ucan_compute_cid(token: Vec) -> String { - macula_rust::ucan::compute_cid(&token) -} - -/// What a provider requires to answer one inbound CALL — a mirror of -/// [`macula_rust::ucan::Policy`], passed to -/// [`FfiSession::serve_one_call_gated`]. `Open` is what -/// [`FfiSession::serve_one_call`] uses internally. -#[derive(uniffi::Enum, Debug, Clone)] -pub enum FfiPolicy { - Open, - Required { issuer: Vec }, -} - -impl TryFrom for macula_rust::ucan::Policy { - type Error = FfiError; - - fn try_from(p: FfiPolicy) -> Result { - Ok(match p { - FfiPolicy::Open => macula_rust::ucan::Policy::open(), - FfiPolicy::Required { issuer } => macula_rust::ucan::Policy::required(to_32(issuer)?), - }) - } -} - -/// Provider role: implement this trait on the foreign side (Kotlin, -/// Swift) to serve inbound unary CALLs — see -/// [`FfiSession::serve_one_call`]. Adapts -/// [`macula_rust::connection::CallHandler`] for the FFI boundary: -/// `handle` receives the full inbound call (`procedure`/`realm`/ -/// `payload`) rather than being looked up from a table first, since a -/// UniFFI foreign trait can't be handed a plain Rust closure the way -/// the core crate's `CallLookup` is — do your own procedure routing -/// inside `handle` if a single session serves more than one procedure. -/// -/// An `Err` reply is always sent as a BOLT#4 `unknown_error` (0x0F) -/// with `reason` as its `detail` — this trait has no way to -/// distinguish "unknown procedure" from any other application-level -/// failure the way the core crate's `CallLookup` can (a synchronous, -/// local table lookup that either finds a handler or doesn't, checked -/// *before* any handler runs): that distinction would need the foreign -/// side to answer a synchronous "do I handle this?" question ahead of -/// the necessarily-async `handle` call, which UniFFI foreign traits -/// don't support today. Nothing behavioral is lost either way — BOLT#4 -/// `unknown_next_peer` and `unknown_error` carry the identical retry -/// classification (`plans/PLAN_WIRE_PROTOCOL.md` §9) — only diagnostic -/// precision. -/// -/// A panic inside `handle` is caught the same way the core crate's own -/// `serve_one_call` catches one (via `tokio::spawn` + -/// `JoinError::is_panic()`) and reported to the caller as BOLT#4 -/// `temporary_relay_failure`, not propagated across the FFI boundary as -/// a Rust panic. -#[uniffi::export(foreign)] -#[async_trait::async_trait] -pub trait FfiCallHandler: Send + Sync { - async fn handle( - &self, - procedure: String, - realm: Vec, - payload: FfiValue, - ) -> Result; -} - -/// What a subscriber receives: a mirror of -/// [`macula_rust::frame::EventInfo`]. -#[derive(uniffi::Record, Debug, Clone)] -pub struct FfiEvent { - pub topic: String, - pub realm: Vec, - pub publisher: Vec, - pub seq: u64, - pub payload: FfiValue, - pub delivered_via: String, -} - -impl TryFrom for FfiEvent { - type Error = FfiError; - - fn try_from(e: macula_rust::frame::EventInfo) -> Result { - Ok(FfiEvent { - topic: e.topic, - realm: e.realm.to_vec(), - publisher: e.publisher.to_vec(), - seq: e.seq, - payload: FfiValue::try_from(e.payload)?, - delivered_via: e.delivered_via, - }) - } -} - -/// `mode` on a stream — mirrors [`macula_rust::frame::StreamMode`]. -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] -pub enum FfiStreamMode { - ServerStream, - ClientStream, - Bidi, -} - -impl From for macula_rust::frame::StreamMode { - fn from(m: FfiStreamMode) -> Self { - match m { - FfiStreamMode::ServerStream => macula_rust::frame::StreamMode::ServerStream, - FfiStreamMode::ClientStream => macula_rust::frame::StreamMode::ClientStream, - FfiStreamMode::Bidi => macula_rust::frame::StreamMode::Bidi, - } - } -} - -impl From for FfiStreamMode { - fn from(m: macula_rust::frame::StreamMode) -> Self { - match m { - macula_rust::frame::StreamMode::ServerStream => FfiStreamMode::ServerStream, - macula_rust::frame::StreamMode::ClientStream => FfiStreamMode::ClientStream, - macula_rust::frame::StreamMode::Bidi => FfiStreamMode::Bidi, - } - } -} - -/// `encoding` on a stream chunk — mirrors -/// [`macula_rust::frame::StreamEncoding`]. A semantic hint, not a -/// second wire codec — see that type's own doc. -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] -pub enum FfiStreamEncoding { - Raw, - Msgpack, -} - -impl From for macula_rust::frame::StreamEncoding { - fn from(e: FfiStreamEncoding) -> Self { - match e { - FfiStreamEncoding::Raw => macula_rust::frame::StreamEncoding::Raw, - FfiStreamEncoding::Msgpack => macula_rust::frame::StreamEncoding::Msgpack, - } - } -} - -impl From for FfiStreamEncoding { - fn from(e: macula_rust::frame::StreamEncoding) -> Self { - match e { - macula_rust::frame::StreamEncoding::Raw => FfiStreamEncoding::Raw, - macula_rust::frame::StreamEncoding::Msgpack => FfiStreamEncoding::Msgpack, - } - } -} - -/// One item received from a stream: a chunk, or a clean end-of-stream. -/// Mirrors [`macula_rust::stream::StreamItem`]. -#[derive(uniffi::Enum, Debug, Clone)] -pub enum FfiStreamItem { - Data { - seq: u64, - encoding: FfiStreamEncoding, - body: FfiValue, - }, - Eof, -} - -/// The terminal result of a `client_stream`/`bidi` exchange — the pair -/// [`macula_rust::stream::StreamHandle::await_reply`] returns. -#[derive(uniffi::Record, Debug, Clone)] -pub struct FfiStreamReply { - pub payload: FfiValue, - pub responded_by: Vec, -} - -/// Provider role: the fields of an inbound STREAM_OPEN needed to decide -/// how to handle it (which procedure, whose call, what arguments) — -/// mirrors [`macula_rust::frame::StreamOpenInfo`]. -#[derive(uniffi::Record, Debug, Clone)] -pub struct FfiStreamOpenInfo { - pub stream_id: Vec, - pub procedure: String, - pub realm: Vec, - pub mode: FfiStreamMode, - pub args: FfiValue, - pub deadline_ms: i64, - pub caller: Vec, -} - -impl TryFrom for FfiStreamOpenInfo { - type Error = FfiError; - - fn try_from(o: macula_rust::frame::StreamOpenInfo) -> Result { - Ok(FfiStreamOpenInfo { - stream_id: o.stream_id.to_vec(), - procedure: o.procedure, - realm: o.realm.to_vec(), - mode: o.mode.into(), - args: FfiValue::try_from(o.args)?, - deadline_ms: o.deadline_ms as i64, - caller: o.caller.to_vec(), - }) - } -} - -/// What [`FfiSession::accept_stream`] hands back: a ready-to-use -/// [`FfiStream`] plus the STREAM_OPEN info that came with it. -#[derive(uniffi::Record)] -pub struct FfiAcceptedStream { - pub stream: std::sync::Arc, - pub info: FfiStreamOpenInfo, -} - -/// What [`FfiSession::open_stream_direct`]/ -/// [`FfiSession::open_stream_direct_with_cert_chain`] hand back: the -/// [`FfiStream`], and its [`FfiSessionLease`] on the session it runs on. -/// Release the lease once the stream is done. -#[derive(uniffi::Record)] -pub struct FfiOpenedDirectStream { - pub stream: std::sync::Arc, - pub lease: std::sync::Arc, -} - -impl From for FfiOpenedDirectStream { - fn from(opened: macula_rust::direct_dial::OpenedStream) -> Self { - Self { - stream: std::sync::Arc::new(FfiStream(tokio::sync::Mutex::new(Some(opened.stream)))), - lease: std::sync::Arc::new(FfiSessionLease(tokio::sync::Mutex::new(Some( - opened.lease, - )))), - } - } -} - -/// A direct-dial stream's use of the session it runs on, wrapping -/// [`macula_rust::direct_dial::SessionLease`]. A session direct dial dialed -/// closes once no direct-dial request still uses it; a session this process -/// already had open under its owner stays open. -#[derive(uniffi::Object)] -pub struct FfiSessionLease(tokio::sync::Mutex>); - -#[uniffi::export(async_runtime = "tokio")] -impl FfiSessionLease { - /// Gives back this use of the session once the stream is done. A no-op - /// once released. - pub async fn release(&self, identity: &FfiKeyPair) { - let lease = self.0.lock().await.take(); - if let Some(lease) = lease { - lease.release(&identity.0).await; - } - } -} - -/// How to trust whatever certificate the station presents — mirrors -/// [`macula_rust::transport::Trust`], minus `Insecure`. -/// -/// `Insecure` (skip TLS verification entirely) is deliberately NOT -/// exposed here: it's a development/diagnostic escape hatch in the core -/// crate, never something a shipped mobile app should be able to -/// select — a stray debug flag left on in production would silently -/// disable all transport security. Reach into the core crate directly -/// (outside this FFI boundary) for that one, if a test harness genuinely -/// needs it. -#[derive(uniffi::Enum, Debug, Clone)] -pub enum FfiTrust { - /// Pin the station's known Ed25519 pubkey (its macula node_id, 32 - /// bytes) — the right mode once a station's identity is known - /// (DHT-resolved, or configured directly), and the ONLY mode that - /// works at all for a station without a CA-issued cert, e.g. a - /// self-hosted/home station outside the public demo fleet — WebPki - /// has no chain to validate there. - Pinned { node_id: Vec }, - /// Standard CA-bundle + hostname validation, for a station whose - /// TLS is terminated by real PKI (e.g. Let's Encrypt) — what the - /// public `station-de-frankfurt.macula.io` demo fleet presents. - WebPki, -} - -impl TryFrom for macula_rust::transport::Trust { - type Error = FfiError; - - fn try_from(t: FfiTrust) -> Result { - match t { - FfiTrust::Pinned { node_id } => { - Ok(macula_rust::transport::Trust::Pinned(to_32(node_id)?)) - } - FfiTrust::WebPki => Ok(macula_rust::transport::Trust::WebPki), - } - } -} - -/// An Ed25519 identity, puzzle-hardened by construction — see -/// [`macula_rust::identity::KeyPair::generate_with_default_puzzle`]'s -/// own doc for why this is always the right default despite its (small, -/// one-time) CPU cost. -#[derive(uniffi::Object)] -pub struct FfiKeyPair(macula_rust::identity::KeyPair); - -#[uniffi::export] -impl FfiKeyPair { - #[uniffi::constructor] - pub fn generate() -> Self { - Self(macula_rust::identity::KeyPair::generate_with_default_puzzle()) - } - - /// Reconstruct a keypair from its 32-byte seed (see - /// [`FfiKeyPair::private_bytes`]) — deterministic, the same seed - /// always yields the same node_id. The seed came from a - /// puzzle-hardened [`generate`](Self::generate) call, so - /// reconstructing from it stays puzzle-valid too; puzzle validity is - /// a property of the public key this seed determines, not something - /// re-checked at reconstruction time. - #[uniffi::constructor] - pub fn from_seed_bytes(seed: Vec) -> Result { - Ok(Self(macula_rust::identity::KeyPair::from_seed_bytes( - to_32(seed)?, - ))) - } - - /// This identity's node_id (its Ed25519 public key), 32 bytes. - pub fn node_id(&self) -> Vec { - self.0.node_id().to_vec() - } - - /// This identity's 32-byte seed. Persist it to restore the SAME - /// identity (same node_id) across restarts via - /// [`FfiKeyPair::from_seed_bytes`] — treat it like a private key, - /// since it deterministically reconstructs this keypair. - pub fn private_bytes(&self) -> Vec { - self.0.private_bytes().to_vec() - } - - /// Persist this identity's seed to the platform's native secure store - /// — Keychain on macOS/iOS, Secret Service on Linux, Credential - /// Manager on Windows, Keystore on Android — instead of handling the - /// raw bytes from [`private_bytes`](Self::private_bytes) yourself. See - /// `macula_rust::keystore`'s module doc for the full platform - /// story, including Android's one-time `initializeNdkContext` setup - /// requirement (unrelated to this method itself — a property of that - /// platform's Keystore, not something this crate can do for you). - /// - /// `service`/`account` address the credential the same way every - /// `keyring` consumer does — e.g. `("com.example.myapp", - /// "macula-identity")` — pick values scoped to your application, since - /// the underlying store is a shared OS-wide facility, not sandboxed to - /// this crate. - pub fn save_to_keystore(&self, service: String, account: String) -> Result<(), FfiError> { - let store = macula_rust::keystore::KeyringStore::new(&service, &account)?; - self.0.save_to_keystore(&store)?; - Ok(()) - } - - /// Reconstruct a keypair previously persisted with - /// [`save_to_keystore`](Self::save_to_keystore). Fails with - /// [`FfiError::KeystoreNotFound`] if nothing has been stored yet under - /// this `service`/`account` pair. - #[uniffi::constructor] - pub fn load_from_keystore(service: String, account: String) -> Result { - let store = macula_rust::keystore::KeyringStore::new(&service, &account)?; - Ok(Self(macula_rust::identity::KeyPair::load_from_keystore( - &store, - )?)) - } -} - -/// A handshaked connection to a macula-station. Wraps -/// [`macula_rust::connection::Session`], a handle, behind a mutex that -/// holds it until [`close`](Self::close). Each method works on a handle -/// cloned out of it, so calls, subscriptions and serving on one session run -/// at the same time. -#[derive(uniffi::Object)] -pub struct FfiSession(tokio::sync::Mutex>); - -impl FfiSession { - /// A handle to the session, cloned out so no lock is held while it works. - async fn session(&self) -> Result { - self.0.lock().await.clone().ok_or(FfiError::Closed) - } -} - -#[uniffi::export(async_runtime = "tokio")] -impl FfiSession { - /// Dial `host:port` and complete the CONNECT/HELLO handshake, using - /// `trust` to validate the station's TLS certificate — see - /// [`FfiTrust`]'s own doc for which mode fits which station. - #[uniffi::constructor] - pub async fn connect( - host: String, - port: u16, - trust: FfiTrust, - identity: &FfiKeyPair, - ) -> Result { - let session = macula_rust::connection::connect(&host, port, trust.try_into()?, &identity.0) - .await - .map_err(|e| FfiError::Connect { - reason: e.to_string(), - })?; - Ok(Self(tokio::sync::Mutex::new(Some(session)))) - } - - /// This session's own connected station's node id (32 bytes), as - /// proven by the HELLO frame's own signature during the handshake. - /// Needed to call [`put_direct`](Self::put_direct) against "whatever - /// station this session is already on" — the common case, and - /// otherwise unreachable through this FFI surface without a - /// [`resolve_direct`](Self::resolve_direct) result to read a station - /// id from instead. - pub async fn station_id(&self) -> Vec { - let guard = self.0.lock().await; - guard - .as_ref() - .map(|s| s.station.node_id.to_vec()) - .unwrap_or_default() - } - - /// Send a signed CALL and wait for the matching RESULT or ERROR. - /// `realm` must be exactly 32 bytes. `timeout_ms` bounds both the - /// wait for a response and the frame's own `deadline_ms` field - /// (`now + timeout_ms`). - pub async fn call( - &self, - procedure: String, - realm: Vec, - payload: FfiValue, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result { - let realm = to_32(realm)?; - let deadline_ms = (now_ms() + timeout_ms) as i128; - - let session = self.session().await?; - let session = &session; - let response = session - .call( - &procedure, - realm, - payload.into(), - deadline_ms, - &identity.0, - std::time::Duration::from_millis(timeout_ms), - ) - .await - .map_err(|e| FfiError::Call { - reason: e.to_string(), - })?; - FfiCallResponse::try_from(response) - } - - /// The provider role's counterpart to [`call`](Self::call): wait for - /// the next inbound CALL, bounded by `timeout_ms`, and dispatch it to - /// `handler` — see [`FfiCallHandler`]. Calls, subscriptions and other - /// serving on this session carry on meanwhile. - pub async fn serve_one_call( - &self, - handler: std::sync::Arc, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - self.serve_one_call_gated(handler, FfiPolicy::Open, timeout_ms, identity) - .await - } - - /// [`serve_one_call`](Self::serve_one_call), additionally gating the - /// inbound CALL through `policy` BEFORE `handler` ever runs — see - /// [`FfiPolicy`]. A rejected caller gets a BOLT#4 `unauthorized` error - /// and never reaches `handler`; `handler` itself never sees the raw - /// UCAN token either way, matching - /// [`macula_rust::connection::Session::serve_one_call_gated`]'s own - /// contract exactly. - pub async fn serve_one_call_gated( - &self, - handler: std::sync::Arc, - policy: FfiPolicy, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let policy: macula_rust::ucan::Policy = policy.try_into()?; - let session = self.session().await?; - let session = &session; - - let lookup = move |realm: &[u8; 32], procedure: &str| { - let handler = handler.clone(); - let realm = realm.to_vec(); - let procedure = procedure.to_string(); - let core_handler: macula_rust::connection::CallHandler = - std::sync::Arc::new(move |payload: macula_rust::cbor::Value| { - let handler = handler.clone(); - let realm = realm.clone(); - let procedure = procedure.clone(); - Box::pin(async move { - let ffi_payload = FfiValue::try_from(payload).map_err(|e| e.to_string())?; - let reply = handler - .handle(procedure, realm, ffi_payload) - .await - .map_err(|e| e.to_string())?; - Ok(macula_rust::cbor::Value::from(reply)) - }) - as macula_rust::connection::BoxFuture< - 'static, - Result, - > - }); - Some(core_handler) - }; - - session - .serve_one_call_gated( - lookup, - move |_, _| policy.clone(), - &identity.0, - std::time::Duration::from_millis(timeout_ms), - ) - .await - .map_err(|e| FfiError::Recv { - reason: e.to_string(), - }) - } - - /// Send a signed PUBLISH. Fire-and-forget — no reply is expected on - /// the wire; a subscriber (this session included, if subscribed to - /// the same topic/realm) receives it asynchronously via - /// [`FfiSubscription::recv_event`]. - /// - /// `seq` and `published_at_ms` are caller-supplied rather than - /// tracked internally — unlike streaming RPC's per-stream counter, - /// PUBLISH's `seq` is a per-publisher, per-topic sequence the mesh - /// uses for gap detection, and a client publishing to several topics - /// has to own that bookkeeping itself; this crate doesn't - /// second-guess it. - pub async fn publish( - &self, - topic: String, - realm: Vec, - seq: u64, - payload: FfiValue, - published_at_ms: u64, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let realm = to_32(realm)?; - let spec = macula_rust::frame::PublishSpec::new( - topic, - realm, - identity.0.node_id(), - seq, - payload.into(), - published_at_ms, - ); - let session = self.session().await?; - let session = &session; - session - .publish(&spec, &identity.0) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - }) - } - - /// Starts a subscription with its own queue of 256 events — see - /// [`macula_rust::connection::Session::subscribe`] for the topic rule. - /// Receive with [`FfiSubscription::recv_event`], and close it when done: - /// the session sends UNSUBSCRIBE once no other subscription on it holds - /// that realm and topic. - pub async fn subscribe( - &self, - topic: String, - realm: Vec, - identity: &FfiKeyPair, - ) -> Result, FfiError> { - let realm = to_32(realm)?; - let spec = macula_rust::frame::SubscribeSpec::new(topic, realm, identity.0.node_id()); - let session = self.session().await?; - let subscription = - session - .subscribe(&spec, &identity.0) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - })?; - Ok(std::sync::Arc::new(FfiSubscription( - tokio::sync::Mutex::new(Some(subscription)), - ))) - } - - /// Send a signed ADVERTISE (§6.9) — registers this session as the - /// handler for `procedure` under `realm`. Fire-and-forget; the - /// station then routes inbound STREAM_OPENs for it back to us as a - /// fresh dedicated stream — see - /// [`accept_stream`](Self::accept_stream). - pub async fn advertise( - &self, - procedure: String, - realm: Vec, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let realm = to_32(realm)?; - let spec = macula_rust::frame::AdvertiseSpec::new(realm, procedure, identity.0.node_id()); - let session = self.session().await?; - let session = &session; - session - .advertise(&spec, &identity.0) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - }) - } - - /// Send a signed UNADVERTISE. Fire-and-forget. - pub async fn unadvertise( - &self, - procedure: String, - realm: Vec, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let realm = to_32(realm)?; - let spec = macula_rust::frame::UnadvertiseSpec::new(realm, procedure, identity.0.node_id()); - let session = self.session().await?; - let session = &session; - session - .unadvertise(&spec, &identity.0) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - }) - } - - /// Direct-dial resolution: finds `procedure`'s currently-advertised - /// serving station and its dialable host/port via the mesh DHT, - /// through this session (used only to query the DHT — it does not - /// need to be connected to the station that will end up serving the - /// call). The provider must have advertised via - /// [`advertise_direct`](Self::advertise_direct) — a plain - /// [`advertise`](Self::advertise) publishes no discoverable record. - /// Most callers want [`call_direct`](Self::call_direct) instead, - /// which does this resolution internally; this is exposed separately - /// for a caller that wants the resolved target itself. - pub async fn resolve_direct( - &self, - procedure: String, - realm: Vec, - identity: &FfiKeyPair, - ) -> Result { - let realm = to_32(realm)?; - let session = self.session().await?; - let session = &session; - let resolved = - macula_rust::direct_dial::resolve(session, &identity.0, realm, &procedure).await?; - Ok(resolved.into()) - } - - /// Resolves `procedure`'s provider via direct-dial (through this - /// session, used only to query the DHT) and calls it there, in one - /// hop, on a session already open to the provider's station under - /// `identity` when there is one — see - /// [`macula_rust::direct_dial::call`]'s own doc for the full trust - /// model. Use this instead of [`call`](Self::call) when the provider - /// is reachable only via [`advertise_direct`](Self::advertise_direct) - /// (e.g. no ordinary advertise-gossip route has propagated between - /// the two stations involved). - pub async fn call_direct( - &self, - procedure: String, - realm: Vec, - payload: FfiValue, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result { - let realm = to_32(realm)?; - let session = self.session().await?; - let session = &session; - let response = macula_rust::direct_dial::call( - session, - &identity.0, - realm, - &procedure, - payload.into(), - std::time::Duration::from_millis(timeout_ms), - ) - .await?; - FfiCallResponse::try_from(response) - } - - /// Publishes a signed direct-dial advertisement for `procedure` naming - /// this session's own currently-connected station — sends the - /// ordinary ADVERTISE frame first (so an inbound CALL routed here the - /// normal way still works, matching - /// [`advertise`](Self::advertise)'s own effect), then publishes a - /// signed `procedure_advertisement` DHT record so a caller on a - /// different station can [`resolve_direct`](Self::resolve_direct)/ - /// [`call_direct`](Self::call_direct) here directly, skipping - /// inter-station gossip propagation. `ttl_ms` is the DHT record's - /// lifetime — this call does not repeat itself; a long-lived provider - /// must call it again on its own schedule before `ttl_ms` elapses - /// (deliberately not wrapped in a background loop here — see this - /// crate's own module doc for why: unlike the core crate's - /// `keep_advertised_direct`, a native background timer inside a - /// mobile app fights the OS's own app-lifecycle/background-execution - /// model; the foreign side should drive its own periodic re-advertise - /// using whatever scheduling mechanism its platform provides, calling - /// this method each time). - pub async fn advertise_direct( - &self, - procedure: String, - realm: Vec, - ttl_ms: u64, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let realm = to_32(realm)?; - let session = self.session().await?; - let session = &session; - macula_rust::direct_dial::advertise_direct( - session, - &identity.0, - realm, - &procedure, - std::time::Duration::from_millis(ttl_ms), - ) - .await - .map_err(FfiError::from) - } - - /// As [`call`](Self::call), attaching `ucan_token` (e.g. from - /// [`ucan_create`]) to the outgoing CALL — for invoking a procedure - /// gated by a [`FfiPolicy::Required`] policy on the provider side. - pub async fn call_with_ucan( - &self, - procedure: String, - realm: Vec, - payload: FfiValue, - timeout_ms: u64, - identity: &FfiKeyPair, - ucan_token: Vec, - ) -> Result { - let realm = to_32(realm)?; - let deadline_ms = (now_ms() + timeout_ms) as i128; - let session = self.session().await?; - let session = &session; - let response = session - .call_with_ucan( - &procedure, - realm, - payload.into(), - deadline_ms, - &identity.0, - std::time::Duration::from_millis(timeout_ms), - ucan_token, - ) - .await - .map_err(|e| FfiError::Call { - reason: e.to_string(), - })?; - FfiCallResponse::try_from(response) - } - - /// The supervised counterpart to the bare [`publish`](Self::publish) - /// primitive — see - /// [`macula_rust::connection::Session::run_publisher`]'s own doc. - /// `announce` controls whether `pubsub.publish_started_v1`/ - /// `pubsub.publish_completed_v1` facts are published around this - /// publish (a fact-publish failure never fails the underlying publish - /// either way). - #[allow(clippy::too_many_arguments)] - pub async fn run_publisher( - &self, - topic: String, - realm: Vec, - seq: u64, - payload: FfiValue, - published_at_ms: u64, - announce: bool, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let realm = to_32(realm)?; - let spec = macula_rust::frame::PublishSpec::new( - topic, - realm, - identity.0.node_id(), - seq, - payload.into(), - published_at_ms, - ); - let session = self.session().await?; - let session = &session; - session - .run_publisher(&spec, &identity.0, announce) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - }) - } - - /// [`resolve_direct`](Self::resolve_direct) plus Slice 7c Direction B - /// managed-realm authorization: only an advertisement whose embedded - /// cert chain validates to `realm_ca_pem` and names `expected_org` is - /// trusted. Opt-in — [`resolve_direct`](Self::resolve_direct) itself is - /// unaffected. - pub async fn resolve_direct_with_cert_chain( - &self, - procedure: String, - realm: Vec, - realm_ca_pem: Vec, - expected_org: String, - identity: &FfiKeyPair, - ) -> Result { - let realm = to_32(realm)?; - let session = self.session().await?; - let session = &session; - let resolved = macula_rust::direct_dial::resolve_with_cert_chain( - session, - &identity.0, - realm, - &procedure, - &realm_ca_pem, - &expected_org, - ) - .await?; - Ok(resolved.into()) - } - - /// [`call_direct`](Self::call_direct), resolved via - /// [`resolve_direct_with_cert_chain`](Self::resolve_direct_with_cert_chain) - /// instead of [`resolve_direct`](Self::resolve_direct) — see both for - /// the full contract. Opt-in managed-realm authorization; - /// [`call_direct`](Self::call_direct) itself is unaffected. - #[allow(clippy::too_many_arguments)] - pub async fn call_direct_with_cert_chain( - &self, - procedure: String, - realm: Vec, - realm_ca_pem: Vec, - expected_org: String, - payload: FfiValue, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result { - let realm = to_32(realm)?; - let session = self.session().await?; - let session = &session; - let response = macula_rust::direct_dial::call_with_cert_chain( - session, - &identity.0, - realm, - &procedure, - &realm_ca_pem, - &expected_org, - payload.into(), - std::time::Duration::from_millis(timeout_ms), - ) - .await?; - FfiCallResponse::try_from(response) - } - - /// [`advertise_direct`](Self::advertise_direct) plus an embedded X.509 - /// service-cert chain, for Slice 7c Direction B managed-realm - /// authorization — see - /// [`resolve_direct_with_cert_chain`](Self::resolve_direct_with_cert_chain)/ - /// [`call_direct_with_cert_chain`](Self::call_direct_with_cert_chain) - /// for the corresponding checks. Opt-in: - /// [`advertise_direct`](Self::advertise_direct) itself is unaffected. - pub async fn advertise_direct_with_cert_chain( - &self, - procedure: String, - realm: Vec, - ttl_ms: u64, - cert_chain_pem: Vec, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let realm = to_32(realm)?; - let session = self.session().await?; - let session = &session; - macula_rust::direct_dial::advertise_direct_with_cert_chain( - session, - &identity.0, - realm, - &procedure, - std::time::Duration::from_millis(ttl_ms), - cert_chain_pem, - ) - .await - .map_err(FfiError::from) - } - - /// Resolves `procedure`'s provider via direct-dial (through this - /// session, used only to query the DHT) and opens a stream to it - /// there, in one hop — mirrors - /// [`call_direct`](Self::call_direct)'s own resolve-then-dial shape for - /// [`stream_open`](Self::stream_open) instead of - /// [`call`](Self::call). The stream runs on a session this process - /// already has open to the provider's station under `identity` when - /// there is one, and otherwise on a new session direct dial opens for - /// it. Release [`FfiOpenedDirectStream::lease`] once the stream is done. - pub async fn open_stream_direct( - &self, - procedure: String, - realm: Vec, - mode: FfiStreamMode, - args: FfiValue, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result { - let realm = to_32(realm)?; - let deadline_ms = (now_ms() + timeout_ms) as i128; - let session = self.session().await?; - let session = &session; - let opened = macula_rust::direct_dial::open_stream_direct( - session, - &identity.0, - realm, - &procedure, - mode.into(), - args.into(), - deadline_ms, - std::time::Duration::from_millis(timeout_ms), - ) - .await - .map_err(|e| FfiError::Resolve { - reason: e.to_string(), - })?; - Ok(FfiOpenedDirectStream::from(opened)) - } - - /// [`open_stream_direct`](Self::open_stream_direct), resolved via - /// [`resolve_direct_with_cert_chain`](Self::resolve_direct_with_cert_chain) - /// instead of [`resolve_direct`](Self::resolve_direct) — see both for - /// the full contract. Opt-in managed-realm authorization; - /// [`open_stream_direct`](Self::open_stream_direct) itself is - /// unaffected. - #[allow(clippy::too_many_arguments)] - pub async fn open_stream_direct_with_cert_chain( - &self, - procedure: String, - realm: Vec, - realm_ca_pem: Vec, - expected_org: String, - mode: FfiStreamMode, - args: FfiValue, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result { - let realm = to_32(realm)?; - let deadline_ms = (now_ms() + timeout_ms) as i128; - let session = self.session().await?; - let session = &session; - let opened = macula_rust::direct_dial::open_stream_direct_with_cert_chain( - session, - &identity.0, - realm, - &procedure, - &realm_ca_pem, - &expected_org, - mode.into(), - args.into(), - deadline_ms, - std::time::Duration::from_millis(timeout_ms), - ) - .await - .map_err(|e| FfiError::Resolve { - reason: e.to_string(), - })?; - Ok(FfiOpenedDirectStream::from(opened)) - } - - /// Stores `data` at a KNOWN `station` (32 bytes) directly, in one hop, - /// instead of going through whatever station this session happens to - /// be connected to — see - /// [`macula_rust::direct_dial::put_direct`]'s own doc. When this - /// process already has a session open to `station` under `identity`, - /// such as this one, the upload runs on that session and leaves it - /// open instead of dialing. - pub async fn put_direct( - &self, - station: Vec, - data: Vec, - name: String, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result, FfiError> { - let station = to_32(station)?; - let session = self.session().await?; - let session = &session; - let mcid = macula_rust::direct_dial::put_direct( - session, - &identity.0, - station, - &data, - name, - std::time::Duration::from_millis(timeout_ms), - ) - .await - .map_err(|e| FfiError::Content { - reason: e.to_string(), - })?; - Ok(mcid.to_vec()) - } - - /// Fetches the content addressed by `mcid` (34 bytes) directly from - /// whichever station announced it, resolved via this session's DHT - /// query — see [`macula_rust::direct_dial::get_direct`]'s own doc. - /// Unlike [`put_direct`](Self::put_direct), no `station` is needed: a - /// `content_announcement` names its own announcer. - pub async fn get_direct( - &self, - mcid: Vec, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result, FfiError> { - let mcid = to_mcid(mcid)?; - let session = self.session().await?; - let session = &session; - macula_rust::direct_dial::get_direct( - session, - &identity.0, - mcid, - std::time::Duration::from_millis(timeout_ms), - ) - .await - .map_err(|e| FfiError::Content { - reason: e.to_string(), - }) - } - - /// Provider role: block for the next inbound STREAM_OPEN, bounded by - /// `timeout_ms`. Only ever succeeds after - /// [`advertise`](Self::advertise) has registered at least one - /// procedure — otherwise the station has nothing to route here. Other - /// methods on this `FfiSession` carry on while it waits. - /// - /// The app decides whether to serve a stream it accepts. One it refuses - /// should get a STREAM_ERROR with macula's codes, `unauthorized` when the - /// caller may not use the procedure and `not_found` for a procedure it - /// doesn't serve, sent with [`FfiStream::refuse`], so a caller sees the - /// same refusal from every stack. - pub async fn accept_stream(&self, timeout_ms: u64) -> Result { - let session = self.session().await?; - let session = &session; - let (handle, info) = macula_rust::stream::StreamHandle::accept( - session, - std::time::Duration::from_millis(timeout_ms), - ) - .await - .map_err(|e| FfiError::Recv { - reason: e.to_string(), - })?; - Ok(FfiAcceptedStream { - stream: std::sync::Arc::new(FfiStream(tokio::sync::Mutex::new(Some(handle)))), - info: FfiStreamOpenInfo::try_from(info)?, - }) - } - - /// Store `data` under a content-address, returning its MCID (34 - /// bytes). `name` is attached to the manifest when `data` is large - /// enough to be chunked; silently unused for a single block, which - /// is addressed purely by content hash — see - /// [`macula_rust::content::put`]'s own doc. - pub async fn content_put( - &self, - data: Vec, - name: String, - identity: &FfiKeyPair, - ) -> Result, FfiError> { - let session = self.session().await?; - let session = &session; - let mcid = macula_rust::content::put(session, &data, name, &identity.0) - .await - .map_err(|e| FfiError::Content { - reason: e.to_string(), - })?; - Ok(mcid.to_vec()) - } - - /// Fetch and verify the content addressed by `mcid` (34 bytes). - pub async fn content_get( - &self, - mcid: Vec, - identity: &FfiKeyPair, - ) -> Result, FfiError> { - let mcid = to_mcid(mcid)?; - let session = self.session().await?; - let session = &session; - macula_rust::content::get(session, mcid, &identity.0) - .await - .map_err(|e| FfiError::Content { - reason: e.to_string(), - }) - } - - /// Open a dedicated stream and send a signed STREAM_OPEN. `realm` - /// must be exactly 32 bytes. `timeout_ms` bounds the frame's own - /// `deadline_ms` field (`now + timeout_ms`); there's no open-time - /// acknowledgement to wait for on the wire — the provider starts - /// reacting to it directly. - pub async fn stream_open( - &self, - procedure: String, - realm: Vec, - mode: FfiStreamMode, - args: FfiValue, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result { - let realm = to_32(realm)?; - let deadline_ms = (now_ms() + timeout_ms) as i128; - let session = self.session().await?; - let session = &session; - let handle = macula_rust::stream::StreamHandle::open( - session, - &procedure, - realm, - mode.into(), - args.into(), - deadline_ms, - &identity.0, - ) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - })?; - Ok(FfiStream(tokio::sync::Mutex::new(Some(handle)))) - } - - /// Close the session with a GOODBYE frame. A no-op if already closed. - pub async fn close(&self, identity: &FfiKeyPair) { - let mut guard = self.0.lock().await; - if let Some(session) = guard.take() { - session.close("normal", None, &identity.0).await; - } - } -} - -/// One subscription on an [`FfiSession`] — wraps -/// [`macula_rust::connection::Subscription`] behind a mutex, the way -/// [`FfiStream`] wraps a stream. Created by [`FfiSession::subscribe`]. -#[derive(uniffi::Object)] -pub struct FfiSubscription(tokio::sync::Mutex>); - -#[uniffi::export(async_runtime = "tokio")] -impl FfiSubscription { - /// Waits up to `timeout_ms` for the next event on this subscription. - /// Fails with [`FfiError::Recv`] when none arrives in time, once the - /// subscription fell more than 256 events behind (after its queued - /// events), and once the session ended. - pub async fn recv_event(&self, timeout_ms: u64) -> Result { - let mut guard = self.0.lock().await; - let subscription = guard.as_mut().ok_or(FfiError::Closed)?; - let event = subscription - .recv_event(std::time::Duration::from_millis(timeout_ms)) - .await - .map_err(|e| FfiError::Recv { - reason: e.to_string(), - })?; - FfiEvent::try_from(event) - } - - /// Ends the subscription, sending UNSUBSCRIBE when no other subscription - /// on the session holds its realm and topic. A no-op if already closed. - pub async fn close(&self) { - let subscription = self.0.lock().await.take(); - if let Some(subscription) = subscription { - subscription.close().await; - } - } -} - -/// A streaming RPC exchange, caller/consumer role — wraps -/// [`macula_rust::stream::StreamHandle`] the same way [`FfiSession`] -/// wraps [`macula_rust::connection::Session`]: a mutex bridges -/// UniFFI's `&self` methods to the core type's `&mut self` ones. Created -/// via [`FfiSession::stream_open`]. -#[derive(uniffi::Object)] -pub struct FfiStream(tokio::sync::Mutex>); - -#[uniffi::export(async_runtime = "tokio")] -impl FfiStream { - /// Send one chunk. - pub async fn send_data( - &self, - encoding: FfiStreamEncoding, - body: FfiValue, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let mut guard = self.0.lock().await; - let handle = guard.as_mut().ok_or(FfiError::Closed)?; - handle - .send_data(encoding.into(), body.into(), &identity.0) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - }) - } - - /// Half-close: signal this side is done sending. For - /// `client_stream`/`bidi` modes, follow with - /// [`await_reply`](Self::await_reply). - pub async fn close_send(&self, identity: &FfiKeyPair) -> Result<(), FfiError> { - let mut guard = self.0.lock().await; - let handle = guard.as_mut().ok_or(FfiError::Closed)?; - handle - .close_send(&identity.0) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - }) - } - - /// Receive the next chunk or end-of-stream, bounded by `timeout_ms`. - pub async fn recv(&self, timeout_ms: u64) -> Result { - let mut guard = self.0.lock().await; - let handle = guard.as_mut().ok_or(FfiError::Closed)?; - let item = handle - .recv(std::time::Duration::from_millis(timeout_ms)) - .await - .map_err(|e| FfiError::Recv { - reason: e.to_string(), - })?; - Ok(match item { - macula_rust::stream::StreamItem::Data { - seq, - encoding, - body, - } => FfiStreamItem::Data { - seq, - encoding: encoding.into(), - body: FfiValue::try_from(body)?, - }, - macula_rust::stream::StreamItem::Eof => FfiStreamItem::Eof, - }) - } - - /// Block for the provider's terminal STREAM_REPLY (`client_stream`/ - /// `bidi` modes only) — call after [`close_send`](Self::close_send). - pub async fn await_reply(&self, timeout_ms: u64) -> Result { - let mut guard = self.0.lock().await; - let handle = guard.as_mut().ok_or(FfiError::Closed)?; - let (payload, responded_by) = handle - .await_reply(std::time::Duration::from_millis(timeout_ms)) - .await - .map_err(|e| FfiError::Recv { - reason: e.to_string(), - })?; - Ok(FfiStreamReply { - payload: FfiValue::try_from(payload)?, - responded_by: responded_by.to_vec(), - }) - } - - /// Provider role: send the terminal STREAM_REPLY a `client_stream`/ - /// `bidi` caller's own `await_reply` is waiting on, once this side - /// has fully consumed and verified whatever the caller streamed. - pub async fn send_reply( - &self, - payload: FfiValue, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let mut guard = self.0.lock().await; - let handle = guard.as_mut().ok_or(FfiError::Closed)?; - handle - .send_reply(payload.into(), &identity.0) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - }) - } - - /// Non-normal termination: send an explicit STREAM_ERROR abort, - /// rather than just dropping the stream — the peer's only signal to - /// tell a cancellation/failure apart from a dropped connection - /// (`plans/PLAN_WIRE_PROTOCOL.md` §13.1, point 4). A no-op if - /// already closed/aborted. - pub async fn abort(&self, code: String, message: String, identity: &FfiKeyPair) { - let mut guard = self.0.lock().await; - if let Some(handle) = guard.take() { - handle.abort(code, message, &identity.0).await; - } - } - - /// Refuses a stream accepted and not served: writes a STREAM_ERROR with - /// `code` and `message`, then finishes sending and stops reading — see - /// [`macula_rust::stream::StreamHandle::refuse`]. A no-op if already - /// closed, aborted or refused. - pub async fn refuse( - &self, - code: String, - message: String, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let handle = self.0.lock().await.take(); - match handle { - Some(handle) => handle - .refuse(code, message, &identity.0) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - }), - None => Ok(()), - } - } -} - -#[cfg(test)] -mod ffi_value_tests { - use super::{FfiError, FfiMapEntry, FfiValue}; - use macula_rust::cbor::Value; - - fn round_trip(v: FfiValue) -> Result { - let core: Value = v.into(); - FfiValue::try_from(core) - } - - #[test] - fn scalars_still_round_trip() { - for v in [ - FfiValue::Null, - FfiValue::Int(-7), - FfiValue::Bytes(vec![1, 2, 3]), - FfiValue::Text("station".to_string()), - FfiValue::Float(1.5), - ] { - assert_eq!(round_trip(v.clone()).unwrap(), v); - } - } - - #[test] - fn empty_list_and_map_round_trip() { - assert_eq!( - round_trip(FfiValue::Items(vec![])).unwrap(), - FfiValue::Items(vec![]) - ); - assert_eq!( - round_trip(FfiValue::Fields(vec![])).unwrap(), - FfiValue::Fields(vec![]) - ); - } - - #[test] - fn flat_list_round_trips() { - let v = FfiValue::Items(vec![ - FfiValue::Int(1), - FfiValue::Text("two".to_string()), - FfiValue::Null, - ]); - assert_eq!(round_trip(v.clone()).unwrap(), v); - } - - #[test] - fn flat_map_round_trips() { - let v = FfiValue::Fields(vec![ - FfiMapEntry { - key: FfiValue::Text("city".to_string()), - value: FfiValue::Text("Milan".to_string()), - }, - FfiMapEntry { - key: FfiValue::Text("lat".to_string()), - value: FfiValue::Float(45.4642), - }, - ]); - assert_eq!(round_trip(v.clone()).unwrap(), v); - } - - /// The actual shape `hecate_stations.list_stations` returns: - /// `#{stations => [#{city => ..., lat => ..., ...}, ...]}` — a map - /// containing a list of maps. This is the case that motivated the - /// fix; a shallow test alone wouldn't have caught a bug in either - /// recursive call. - #[test] - fn map_containing_list_of_maps_round_trips() { - let station = |city: &str| { - FfiValue::Fields(vec![ - FfiMapEntry { - key: FfiValue::Text("city".to_string()), - value: FfiValue::Text(city.to_string()), - }, - FfiMapEntry { - key: FfiValue::Text("capabilities".to_string()), - value: FfiValue::Int(0), - }, - ]) - }; - let v = FfiValue::Fields(vec![FfiMapEntry { - key: FfiValue::Text("stations".to_string()), - value: FfiValue::Items(vec![station("Milan"), station("Paris")]), - }]); - assert_eq!(round_trip(v.clone()).unwrap(), v); - } - - /// A non-text map key must survive too — `Value::Map`'s keys are - /// arbitrary values, not just text (see `FfiValue::Fields`'s own doc). - #[test] - fn integer_keyed_map_round_trips() { - let v = FfiValue::Fields(vec![ - FfiMapEntry { - key: FfiValue::Int(0), - value: FfiValue::Text("a".to_string()), - }, - FfiMapEntry { - key: FfiValue::Int(1), - value: FfiValue::Text("b".to_string()), - }, - ]); - assert_eq!(round_trip(v.clone()).unwrap(), v); - } - - /// `i128` values outside `i64` range must still fail cleanly even - /// nested inside a list -- the recursive `?`/`map_err` chain must - /// propagate the error rather than swallowing or panicking. - #[test] - fn out_of_range_int_inside_list_errors_not_panics() { - let too_big = Value::List(vec![Value::Int(i128::MAX)]); - let err = FfiValue::try_from(too_big).unwrap_err(); - assert!(matches!(err, FfiError::UnrepresentableValue { .. })); - } -} diff --git a/macula-rust-ffi/src/node_key.rs b/macula-rust-ffi/src/node_key.rs new file mode 100644 index 0000000..512f366 --- /dev/null +++ b/macula-rust-ffi/src/node_key.rs @@ -0,0 +1,130 @@ +//! A node's identity key: made in either profile with the admission puzzle +//! solved, and kept in an owner-only key file or the platform's secure +//! store (Keychain on iOS, the Android Keystore; see +//! [`macula_rust::keystore`] for the one-time Android setup). + +use std::path::Path; +use std::sync::Arc; + +use macula_rust::keystore::{KeyStoreError, KeyringStore}; +use macula_rust::node_key::{KeyFileError, NodeKey, Purpose, PUZZLE_DIFFICULTY}; +use macula_rust::profile::Profile; + +use crate::FfiError; + +/// macula 12's two profiles: ML-DSA-87 alone, or ML-DSA-87 with RSA-PSS-4096 +/// as a LAMPS composite. +#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum FfiProfile { + PqPure, + PqHybrid, +} + +impl From for Profile { + fn from(p: FfiProfile) -> Self { + match p { + FfiProfile::PqPure => Profile::PqPure, + FfiProfile::PqHybrid => Profile::PqHybrid, + } + } +} + +impl From for FfiProfile { + fn from(p: Profile) -> Self { + match p { + Profile::PqPure => FfiProfile::PqPure, + Profile::PqHybrid => FfiProfile::PqHybrid, + } + } +} + +/// A node's identity key. +#[derive(uniffi::Object)] +pub struct FfiNodeKey(pub(crate) Arc); + +impl From for FfiError { + fn from(e: KeyFileError) -> Self { + match e { + KeyFileError::KeyStore(KeyStoreError::NotFound) => FfiError::KeystoreNotFound, + KeyFileError::KeyStore(other) => FfiError::Keystore { + message: other.to_string(), + }, + other => FfiError::Key { + message: other.to_string(), + }, + } + } +} + +impl From for FfiError { + fn from(e: KeyStoreError) -> Self { + KeyFileError::KeyStore(e).into() + } +} + +#[uniffi::export] +impl FfiNodeKey { + /// A new identity key in `profile` whose node_id solves the admission + /// puzzle stations require. A pq_hybrid key takes a few seconds. + #[uniffi::constructor] + pub fn generate(profile: FfiProfile) -> Result, FfiError> { + let key = NodeKey::generate_identity(profile.into(), PUZZLE_DIFFICULTY).map_err(|e| { + FfiError::Key { + message: e.to_string(), + } + })?; + Ok(Arc::new(FfiNodeKey(Arc::new(key)))) + } + + /// The identity key in the key file at `path`, which must be owner-only + /// and hold a key of `profile`. + #[uniffi::constructor] + pub fn load(path: String, profile: FfiProfile) -> Result, FfiError> { + let key = NodeKey::load(Path::new(&path), Purpose::Identity, profile.into())?; + Ok(Arc::new(FfiNodeKey(Arc::new(key)))) + } + + /// The identity key the platform's secure store holds under `service` + /// and `account`, as [`save_to_keystore`](Self::save_to_keystore) put it. + #[uniffi::constructor] + pub fn load_from_keystore( + service: String, + account: String, + profile: FfiProfile, + ) -> Result, FfiError> { + let store = KeyringStore::new(&service, &account)?; + let key = NodeKey::load_from_keystore(&store, Purpose::Identity, profile.into())?; + Ok(Arc::new(FfiNodeKey(Arc::new(key)))) + } + + /// Writes the key to an owner-only key file at `path`. + pub fn save(&self, path: String) -> Result<(), FfiError> { + Ok(self.0.save(Path::new(&path))?) + } + + /// Keeps the key in the platform's secure store under `service` and + /// `account`, e.g. ("com.example.app", "macula-identity"): the store is + /// shared by the whole device, so scope them to the app. + pub fn save_to_keystore(&self, service: String, account: String) -> Result<(), FfiError> { + let store = KeyringStore::new(&service, &account)?; + Ok(self.0.save_to_keystore(&store)?) + } + + /// The node_id the key proves, 32 bytes. + pub fn node_id(&self) -> Vec { + self.0 + .node_id() + .expect("an identity key always has a node_id") + .to_vec() + } + + /// The key's profile. + pub fn profile(&self) -> FfiProfile { + self.0.profile().into() + } + + /// The public key as carried on the wire. + pub fn public_key(&self) -> Vec { + self.0.public_key() + } +} diff --git a/macula-rust-ffi/src/pool.rs b/macula-rust-ffi/src/pool.rs new file mode 100644 index 0000000..d84a497 --- /dev/null +++ b/macula-rust-ffi/src/pool.rs @@ -0,0 +1,237 @@ +//! A node's pool of station links: one link per pinned seed, redialed when it +//! drops; calls to a provider at its own station, trusted only under the +//! realm keys the pool pins; and DHT records. + +use std::collections::HashMap; +use std::sync::Arc; + +use macula_rust::pool::{Call, Opts, Pool, Seed}; +use macula_rust::record::{self, RecordType, Verified}; + +use crate::node_key::FfiNodeKey; +use crate::{millis, to_32, FfiError, FfiValue}; + +/// A station to link to: where it is dialed and the node_id it must prove. +#[derive(uniffi::Record, Debug, Clone, PartialEq)] +pub struct FfiSeed { + pub host: String, + pub port: u16, + pub node_id: Vec, +} + +/// A realm's key as carried, the key its members pin. +#[derive(uniffi::Record, Debug, Clone, PartialEq)] +pub struct FfiRealmKey { + pub realm: Vec, + pub key: Vec, +} + +/// A pool's options. Zero for a number is macula's default. +#[derive(uniffi::Record, Debug, Clone, PartialEq, Default)] +pub struct FfiPoolOptions { + /// The realms whose org procedures the pool trusts and serves. + #[uniffi(default = [])] + pub realm_trust: Vec, + #[uniffi(default = 0)] + pub connect_timeout_ms: u64, + #[uniffi(default = 0)] + pub respawn_delay_ms: u64, + #[uniffi(default = 0)] + pub replication_factor: u32, + #[uniffi(default = 0)] + pub max_seeds: u32, + #[uniffi(default = 0)] + pub max_direct_links: u32, + /// Try the links in a fresh random order each time, not seed order. + #[uniffi(default = false)] + pub random_link_order: bool, +} + +/// One of the pool's links. +#[derive(uniffi::Record, Debug, Clone, PartialEq)] +pub struct FfiLinkStatus { + pub station: Vec, + pub host: String, + pub port: u16, + pub direct: bool, + pub up: bool, +} + +/// A node serving a procedure, and the station it serves from. +#[derive(uniffi::Record, Debug, Clone, PartialEq)] +pub struct FfiProvider { + pub node: Vec, + pub station: Vec, +} + +/// A DHT record that verified: its type, its signer's key id, its times, +/// its payload, and its wire bytes. +#[derive(uniffi::Record, Debug, Clone, PartialEq)] +pub struct FfiRecord { + pub record_type: u8, + pub signer: Vec, + pub created_at: u64, + pub expires_at: u64, + pub payload: FfiValue, + pub wire: Vec, +} + +impl TryFrom for FfiRecord { + type Error = FfiError; + + fn try_from(v: Verified) -> Result { + let r = v.into_record(); + let wire = record::encode(&r).map_err(|e| FfiError::Other { + message: e.to_string(), + })?; + Ok(FfiRecord { + record_type: r.record_type.0, + signer: r + .signed + .as_ref() + .map(|s| s.key_id.to_vec()) + .unwrap_or_default(), + created_at: r.created_at, + expires_at: r.expires_at, + payload: r.payload.try_into()?, + wire, + }) + } +} + +/// `name` in the own namespace of `node`, `~/name`: a procedure +/// its node serves with no realm key, authorized by its signature alone. +#[uniffi::export] +pub fn own_procedure(node: Vec, name: String) -> Result { + Ok(record::own_procedure(&to_32(node)?, &name)) +} + +/// A node's station links. +#[derive(uniffi::Object)] +pub struct FfiPool(pub(crate) Pool); + +#[uniffi::export(async_runtime = "tokio")] +impl FfiPool { + /// Links to every seed as `key`, and returns once one link is up. + #[uniffi::constructor] + pub async fn connect( + key: Arc, + seeds: Vec, + options: FfiPoolOptions, + ) -> Result, FfiError> { + let mut opts = Opts::new(key.0.clone()); + let mut trust = HashMap::new(); + for r in options.realm_trust { + trust.insert(to_32(r.realm)?, r.key); + } + opts.realm_trust = trust; + opts.connect_timeout = millis(options.connect_timeout_ms); + opts.respawn_delay = millis(options.respawn_delay_ms); + opts.replication_factor = options.replication_factor as usize; + opts.max_seeds = options.max_seeds as usize; + opts.max_direct_links = options.max_direct_links as usize; + if options.random_link_order { + opts.link_selection = macula_rust::pool::LinkSelection::Random; + } + let seeds = seeds + .into_iter() + .map(|s| { + Ok(Seed { + host: s.host, + port: s.port, + node_id: to_32(s.node_id)?, + }) + }) + .collect::, FfiError>>()?; + Ok(Arc::new(FfiPool(Pool::connect(seeds, opts).await?))) + } + + /// The node_id the pool links as. + pub fn node_id(&self) -> Vec { + self.0.node_id().to_vec() + } + + /// Every link the pool holds, seeds first. + pub fn status(&self) -> Vec { + self.0 + .status() + .into_iter() + .map(|l| FfiLinkStatus { + station: l.station.to_vec(), + host: l.host, + port: l.port, + direct: l.direct, + up: l.up, + }) + .collect() + } + + /// Ends every link with a GOODBYE, and every subscription. + pub async fn close(&self) { + self.0.close().await; + } + + /// Calls `procedure` in `realm` at a provider that serves it (`provider`, + /// or any trusted one when `None`), waiting up to `timeout_ms` (0 for + /// macula's 5 seconds). A provider's ERROR is [`FfiError::Provider`]. + pub async fn call( + &self, + realm: Vec, + procedure: String, + payload: FfiValue, + provider: Option>, + timeout_ms: u64, + ) -> Result { + let answered = self + .0 + .call(Call { + realm: to_32(realm)?, + procedure, + provider: provider.map(to_32).transpose()?.unwrap_or([0; 32]), + payload: payload.into(), + timeout: millis(timeout_ms), + ..Call::default() + }) + .await?; + answered.try_into() + } + + /// Every provider of `procedure` in `realm` the pinned realm key + /// authorizes, freshest first. + pub async fn providers( + &self, + realm: Vec, + procedure: String, + ) -> Result, FfiError> { + let found = self.0.providers(&to_32(realm)?, &procedure).await?; + Ok(found + .into_iter() + .map(|p| FfiProvider { + node: p.node.to_vec(), + station: p.station.to_vec(), + }) + .collect()) + } + + /// The record under `key`, verified. + pub async fn find_record(&self, key: Vec) -> Result { + self.0.find_record(&to_32(key)?).await?.try_into() + } + + /// The records under `key` that verify. + pub async fn find_records(&self, key: Vec) -> Result, FfiError> { + let (found, _) = self.0.find_records(&to_32(key)?).await?; + found.into_iter().map(FfiRecord::try_from).collect() + } + + /// The records of `record_type` the station holds that verify. + pub async fn find_records_by_type(&self, record_type: u8) -> Result, FfiError> { + let (found, _) = self.0.find_records_by_type(RecordType(record_type)).await?; + found.into_iter().map(FfiRecord::try_from).collect() + } + + /// Puts a signed record, as its wire bytes, in the DHT. + pub async fn put_record(&self, wire: Vec) -> Result<(), FfiError> { + Ok(self.0.put_record(&wire).await?) + } +} diff --git a/macula-rust-ffi/src/pubsub.rs b/macula-rust-ffi/src/pubsub.rs new file mode 100644 index 0000000..af0b82d --- /dev/null +++ b/macula-rust-ffi/src/pubsub.rs @@ -0,0 +1,117 @@ +//! PubSub through the pool: a publication signed once and sent on the +//! pool's links, and a subscription on every link, each event delivered +//! once. The foreign side reads a subscription with +//! [`FfiSubscription::next`] in a loop of its own, which fits a mobile app's +//! lifecycle better than a native background loop would. + +use std::sync::Arc; + +use macula_rust::pool::Subscription; +use macula_rust::station_link::{Event, Publication}; +use tokio::sync::Mutex; + +use crate::pool::FfiPool; +use crate::{millis, to_32, FfiError, FfiValue}; + +/// A publication a subscription heard, verified: who published it, where, +/// its seq and time, the payload, and how it arrived. +#[derive(uniffi::Record, Debug, Clone, PartialEq)] +pub struct FfiEvent { + pub publisher: Vec, + pub realm: Vec, + pub topic: String, + pub seq: u64, + pub published_at: u64, + pub payload: FfiValue, + pub delivered_via: String, +} + +impl TryFrom for FfiEvent { + type Error = FfiError; + + fn try_from(e: Event) -> Result { + Ok(FfiEvent { + publisher: e.publisher.to_vec(), + realm: e.realm.to_vec(), + topic: e.topic, + seq: e.seq, + published_at: e.published_at, + payload: e.payload.try_into()?, + delivered_via: e.delivered_via, + }) + } +} + +/// The node's subscription to a realm and topic, until +/// [`unsubscribe`](Self::unsubscribe). +#[derive(uniffi::Object)] +pub struct FfiSubscription(Mutex>); + +#[uniffi::export(async_runtime = "tokio")] +impl FfiPool { + /// Signs a publication once and sends it on the pool's links. `ttl_ms` + /// of `None` is macula's 10 minutes. + pub async fn publish( + &self, + realm: Vec, + topic: String, + payload: FfiValue, + ttl_ms: Option, + ) -> Result<(), FfiError> { + Ok(self + .0 + .publish(Publication { + realm: to_32(realm)?, + topic, + payload: payload.into(), + ttl_ms, + }) + .await?) + } + + /// Subscribes the node to `topic` in `realm` on every link. + pub async fn subscribe( + &self, + realm: Vec, + topic: String, + ) -> Result, FfiError> { + let sub = self.0.subscribe(&to_32(realm)?, &topic).await?; + Ok(Arc::new(FfiSubscription(Mutex::new(Some(sub))))) + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl FfiSubscription { + /// The next event, or `None` when none arrives within `timeout_ms`. + /// [`FfiError::Closed`] once the subscription or its pool has ended. + pub async fn next(&self, timeout_ms: u64) -> Result, FfiError> { + let mut held = self.0.lock().await; + let sub = held.as_mut().ok_or(FfiError::Closed)?; + match tokio::time::timeout(millis(timeout_ms), sub.recv()).await { + Err(_) => Ok(None), + Ok(None) => { + *held = None; + Err(FfiError::Closed) + } + Ok(Some(event)) => Ok(Some(event.try_into()?)), + } + } + + /// How many events arrived while the subscription was full. + pub async fn dropped(&self) -> u64 { + self.0 + .lock() + .await + .as_ref() + .map(|s| s.dropped()) + .unwrap_or(0) + } + + /// Ends the subscription on every link. + pub async fn unsubscribe(&self) -> Result<(), FfiError> { + let Some(mut sub) = self.0.lock().await.take() else { + return Ok(()); + }; + Ok(sub.unsubscribe().await?) + } +} diff --git a/macula-rust-ffi/src/serve.rs b/macula-rust-ffi/src/serve.rs new file mode 100644 index 0000000..3b30832 --- /dev/null +++ b/macula-rust-ffi/src/serve.rs @@ -0,0 +1,121 @@ +//! Serving a procedure with a handler the foreign side implements, on every +//! link the pool holds and every link it dials later, until stopped. A +//! handler's [`FfiError`] reaches the caller as a handler_error with the +//! error's text; a handler that throws anything else, or panics, is answered +//! as macula answers a crashed handler. + +use std::sync::Arc; + +use macula_rust::pool::{Offer, Served}; +use macula_rust::station_link::{handler, stream_handler, Request}; + +use crate::pool::FfiPool; +use crate::stream::{FfiStream, FfiStreamHandler, FfiStreamMode}; +use crate::{to_32, FfiError, FfiValue}; + +/// A CALL a served procedure answers: the caller's node_id (the key its +/// signature verified under), what it asked for, and its deadline in unix +/// milliseconds. +#[derive(uniffi::Record, Debug, Clone, PartialEq)] +pub struct FfiRequest { + pub caller: Vec, + pub realm: Vec, + pub procedure: String, + pub payload: FfiValue, + pub deadline_ms: u64, +} + +impl TryFrom for FfiRequest { + type Error = FfiError; + + fn try_from(r: Request) -> Result { + Ok(FfiRequest { + caller: r.caller.to_vec(), + realm: r.realm.to_vec(), + procedure: r.procedure, + payload: r.payload.try_into()?, + deadline_ms: r.deadline_ms, + }) + } +} + +/// Answers the CALLs of a procedure the node serves, implemented in Kotlin +/// or Swift. +#[uniffi::export(with_foreign)] +#[async_trait::async_trait] +pub trait FfiCallHandler: Send + Sync { + async fn handle(&self, request: FfiRequest) -> Result; +} + +/// A procedure the node serves, until [`stop`](Self::stop). +#[derive(uniffi::Object)] +pub struct FfiServed(Served); + +#[uniffi::export(async_runtime = "tokio")] +impl FfiPool { + /// Serves `procedure` in `realm` with `handler` on every link. An org + /// procedure needs its realm's key pinned; one in the node's own + /// namespace (see [`own_procedure`](crate::own_procedure)) needs none. + pub async fn serve( + &self, + realm: Vec, + procedure: String, + handler_impl: Arc, + ) -> Result, FfiError> { + let answer = handler(move |r: Request| { + let handler_impl = handler_impl.clone(); + async move { + let request = FfiRequest::try_from(r).map_err(|e| e.to_string())?; + let answered = handler_impl + .handle(request) + .await + .map_err(|e| e.to_string())?; + Ok(answered.into()) + } + }); + let served = self + .0 + .serve(Offer::unary(to_32(realm)?, &procedure, answer)) + .await?; + Ok(Arc::new(FfiServed(served))) + } + + /// Serves `procedure` in `realm` as a streaming procedure of `mode`, + /// each session handed to `handler_impl`: a session it returns from + /// without ending is closed, and one it fails is aborted. + pub async fn serve_stream( + &self, + realm: Vec, + procedure: String, + mode: FfiStreamMode, + handler_impl: Arc, + ) -> Result, FfiError> { + let session = stream_handler(move |s| { + let handler_impl = handler_impl.clone(); + async move { + handler_impl + .handle(Arc::new(FfiStream::new(s))) + .await + .map_err(|e| e.to_string()) + } + }); + let served = self + .0 + .serve(Offer::stream( + to_32(realm)?, + &procedure, + mode.into(), + session, + )) + .await?; + Ok(Arc::new(FfiServed(served))) + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl FfiServed { + /// Withdraws the procedure on every link. + pub async fn stop(&self) -> Result<(), FfiError> { + Ok(self.0.stop().await?) + } +} diff --git a/macula-rust-ffi/src/stream.rs b/macula-rust-ffi/src/stream.rs new file mode 100644 index 0000000..4b6cd0a --- /dev/null +++ b/macula-rust-ffi/src/stream.rs @@ -0,0 +1,181 @@ +//! Streaming sessions, on either side: opened at a provider through the +//! pool, or handed to a [`FfiStreamHandler`] the node serves with. Each side +//! sends chunks and ends its sending; a client_stream or bidi provider ends +//! the session with a reply. + +use std::sync::Arc; + +use macula_rust::frame::{StreamEncoding, StreamMode, StreamRole}; +use macula_rust::pool::StreamCall; +use macula_rust::station_link::{Stream, StreamEvent}; + +use crate::pool::FfiPool; +use crate::{millis, to_32, FfiError, FfiValue}; + +/// Who pushes data on a stream: the provider, the caller, or both. +#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum FfiStreamMode { + ServerStream, + ClientStream, + Bidi, +} + +impl From for StreamMode { + fn from(m: FfiStreamMode) -> Self { + match m { + FfiStreamMode::ServerStream => StreamMode::ServerStream, + FfiStreamMode::ClientStream => StreamMode::ClientStream, + FfiStreamMode::Bidi => StreamMode::Bidi, + } + } +} + +/// How a chunk's body reads: raw bytes, or a structured value. +#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum FfiStreamEncoding { + Raw, + Structured, +} + +/// One frame the peer sent, verified: a chunk, the peer's end (`both` when +/// it ends the whole session, not just the peer's sending), or the +/// provider's terminal reply. +#[derive(uniffi::Enum, Debug, Clone, PartialEq)] +pub enum FfiStreamEvent { + Data { + encoding: FfiStreamEncoding, + body: FfiValue, + }, + End { + both: bool, + }, + Reply { + payload: FfiValue, + }, +} + +impl TryFrom for FfiStreamEvent { + type Error = FfiError; + + fn try_from(e: StreamEvent) -> Result { + Ok(match e { + StreamEvent::Data { encoding, body } => FfiStreamEvent::Data { + encoding: match encoding { + StreamEncoding::Raw => FfiStreamEncoding::Raw, + StreamEncoding::Msgpack => FfiStreamEncoding::Structured, + }, + body: body.try_into()?, + }, + StreamEvent::End { role } => FfiStreamEvent::End { + both: role == StreamRole::Both, + }, + StreamEvent::Reply { payload } => FfiStreamEvent::Reply { + payload: payload.try_into()?, + }, + }) + } +} + +/// Serves one streaming session, implemented in Kotlin or Swift. A session +/// it returns from without ending is closed on both sides; one it fails is +/// aborted with code `error` and the error's text. +#[uniffi::export(with_foreign)] +#[async_trait::async_trait] +pub trait FfiStreamHandler: Send + Sync { + async fn handle(&self, stream: Arc) -> Result<(), FfiError>; +} + +/// One streaming session, on either side. +#[derive(uniffi::Object)] +pub struct FfiStream(Stream); + +impl FfiStream { + pub(crate) fn new(s: Stream) -> FfiStream { + FfiStream(s) + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl FfiPool { + /// Opens a streaming session of `mode` at a provider of `procedure` in + /// `realm` (`provider`, or any trusted one when `None`), its deadline + /// `deadline_ms` ahead (0 for macula's 30 seconds). A refusal arrives on + /// the first [`FfiStream::recv`]. + pub async fn open_stream( + &self, + realm: Vec, + procedure: String, + mode: FfiStreamMode, + payload: FfiValue, + provider: Option>, + deadline_ms: u64, + ) -> Result, FfiError> { + let stream = self + .0 + .open_stream(StreamCall { + realm: to_32(realm)?, + procedure, + provider: provider.map(to_32).transpose()?.unwrap_or([0; 32]), + mode: mode.into(), + payload: payload.into(), + deadline: millis(deadline_ms), + ..StreamCall::default() + }) + .await?; + Ok(Arc::new(FfiStream(stream))) + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl FfiStream { + /// The session's caller, its node_id. + pub fn caller(&self) -> Vec { + self.0.request().caller.to_vec() + } + + /// The payload the session was opened with. + pub fn open_payload(&self) -> Result { + self.0.request().payload.clone().try_into() + } + + /// Sends a raw chunk. + pub async fn send(&self, body: Vec) -> Result<(), FfiError> { + Ok(self.0.send(&body).await?) + } + + /// Sends a structured chunk. + pub async fn send_value(&self, value: FfiValue) -> Result<(), FfiError> { + Ok(self.0.send_value(value.into()).await?) + } + + /// Ends this side's sending; the peer may still send. + pub async fn close_send(&self) -> Result<(), FfiError> { + Ok(self.0.close_send().await?) + } + + /// Ends the session on both sides. + pub async fn close(&self) -> Result<(), FfiError> { + Ok(self.0.close().await?) + } + + /// Sends the provider's terminal value and ends the session. + pub async fn reply(&self, payload: FfiValue) -> Result<(), FfiError> { + Ok(self.0.reply(payload.into()).await?) + } + + /// Ends the session with an error of `code` and `message`. + pub async fn abort(&self, code: String, message: String) -> Result<(), FfiError> { + Ok(self.0.abort(&code, &message).await?) + } + + /// The next frame the peer sent, waiting up to `timeout_ms` + /// ([`FfiError::Timeout`] past it). Once the session has ended and every + /// frame before is read: [`FfiError::EndOfStream`] for a normal end, + /// [`FfiError::Stream`] for an error. + pub async fn recv(&self, timeout_ms: u64) -> Result { + match tokio::time::timeout(millis(timeout_ms), self.0.recv()).await { + Err(_) => Err(FfiError::Timeout), + Ok(event) => event?.try_into(), + } + } +} diff --git a/macula-rust-ffi/tests/live_cert_chain_direct_dial.rs b/macula-rust-ffi/tests/live_cert_chain_direct_dial.rs deleted file mode 100644 index d6404a4..0000000 --- a/macula-rust-ffi/tests/live_cert_chain_direct_dial.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! Proves `resolve_direct_with_cert_chain`/`call_direct_with_cert_chain`/ -//! `advertise_direct_with_cert_chain` work end-to-end THROUGH the FFI -//! surface, mirroring `../../tests/live_cert_chain.rs`'s own self-issued -//! trust anchor (cert-chain authorization is a client-side check on an -//! opaque DHT payload the station itself never inspects, so no fleet -//! provisioning is needed). -//! -//! Not run by default CI — `#[ignore]`d, matching this crate's other live -//! tests. Run explicitly with: -//! `cargo test -p macula-rust-ffi --test live_cert_chain_direct_dial -- --ignored --nocapture` - -use macula_rust_ffi::{ - FfiCallHandler, FfiCallResponse, FfiError, FfiKeyPair, FfiSession, FfiTrust, FfiValue, -}; -use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair as RcgenKeyPair}; -use std::sync::Arc; -use std::time::Duration; - -const STATION_HOST: &str = "station-de-frankfurt.macula.io"; -const STATION_PORT: u16 = 4433; - -fn short_hex(bytes: &[u8]) -> String { - bytes.iter().take(8).map(|b| format!("{b:02x}")).collect() -} - -/// Records the procedure of every CALL it answers -- see -/// `live_ffi.rs`'s identical `RecordingEchoHandler` for the full -/// reasoning (a one-shot `serve_one_call` answering SOMETHING is not -/// proof it answered THIS test's own call, on a shared public fleet). -struct RecordingEchoHandler { - served: Arc>>, -} - -#[async_trait::async_trait] -impl FfiCallHandler for RecordingEchoHandler { - async fn handle( - &self, - procedure: String, - _realm: Vec, - payload: FfiValue, - ) -> Result { - self.served.lock().await.push(procedure); - Ok(payload) - } -} - -/// `serve_one_call` accepts the next inbound CALL unconditionally -- it -/// doesn't filter by procedure. On this shared public fleet a stray -/// unrelated CALL can arrive first; loop past it via a recording handler -/// rather than assuming the first one-shot serve answers this test's own -/// call. A first draft here returned `Ok` on ANY successful serve -/// regardless of which procedure it answered -- real bug, reproduced -/// live (a stray call satisfied the loop while this test's own call went -/// unanswered until it timed out) -- fixed to match `live_ffi.rs`'s -/// already-correct `serve_until_procedure`. -async fn serve_until_procedure( - session: &FfiSession, - procedure: &str, - per_attempt_timeout_ms: u64, - max_attempts: u32, - identity: &FfiKeyPair, -) -> Result<(), FfiError> { - let served = Arc::new(tokio::sync::Mutex::new(Vec::new())); - for _ in 0..max_attempts { - let handler = Arc::new(RecordingEchoHandler { - served: Arc::clone(&served), - }); - match session - .serve_one_call(handler, per_attempt_timeout_ms, identity) - .await - { - Ok(()) => { - if served.lock().await.iter().any(|p| p == procedure) { - return Ok(()); - } - } - Err(FfiError::Recv { .. }) => {} - Err(other) => return Err(other), - } - } - Ok(()) -} - -fn self_issued_realm_ca() -> (Vec, rcgen::Issuer<'static, RcgenKeyPair>) { - let key_pair = RcgenKeyPair::generate_for(&rcgen::PKCS_ED25519).expect("ca keygen"); - let mut params = CertificateParams::new(Vec::::new()).expect("ca params"); - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "Live FFI Test Realm CA"); - dn.push(DnType::OrganizationName, "Live FFI Test Realm CA"); - params.distinguished_name = dn; - params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); - params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1); - params.not_after = time::OffsetDateTime::now_utc() + time::Duration::hours(1); - let cert = params.self_signed(&key_pair).expect("ca self-sign"); - let pem = cert.pem().into_bytes(); - (pem, rcgen::Issuer::new(params, key_pair)) -} - -/// RFC 8410 SubjectPublicKeyInfo DER for a raw 32-byte Ed25519 pubkey — -/// duplicated from the core crate's own `tests/live_cert_chain.rs`, which -/// duplicates it from `src/cert_chain.rs`'s own `#[cfg(test)]` helper for -/// the identical reason (not reachable across crate/module boundaries). -fn ed25519_spki_der(pubkey: [u8; 32]) -> Vec { - let mut der = vec![ - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, - ]; - der.extend_from_slice(&pubkey); - der -} - -fn issue_leaf( - ca_issuer: &rcgen::Issuer<'static, RcgenKeyPair>, - advertiser_pub: [u8; 32], - org: &str, -) -> Vec { - let subject_spki = - rcgen::SubjectPublicKeyInfo::from_der(&ed25519_spki_der(advertiser_pub)).expect("spki"); - let mut params = CertificateParams::new(Vec::::new()).expect("leaf params"); - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "live-ffi-cert-chain-test-service"); - dn.push(DnType::OrganizationName, org); - params.distinguished_name = dn; - params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1); - params.not_after = time::OffsetDateTime::now_utc() + time::Duration::hours(1); - let cert = params - .signed_by(&subject_spki, ca_issuer) - .expect("leaf signed_by"); - cert.der().to_vec() -} - -fn pem_bundle(ders: &[Vec]) -> Vec { - use base64::Engine; - let mut out = Vec::new(); - for der in ders { - let b64 = base64::engine::general_purpose::STANDARD.encode(der); - out.extend_from_slice(b"-----BEGIN CERTIFICATE-----\n"); - for chunk in b64.as_bytes().chunks(64) { - out.extend_from_slice(chunk); - out.push(b'\n'); - } - out.extend_from_slice(b"-----END CERTIFICATE-----\n"); - } - out -} - -/// Publishes a `cert_chain`-bearing advertisement via -/// `advertise_direct_with_cert_chain`, serves through it, and calls it via -/// `call_direct_with_cert_chain` from a separate session/identity — a real -/// RESULT, not just a reached-the-call-stage outcome (see this session's -/// own history for why that weaker bar already hid a real bug once). -/// Includes the negative control: the SAME chain correctly fails -/// authorization when a caller expects the wrong org. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn cert_chain_direct_dial_round_trip_through_the_ffi_surface() { - let (ca_pem, ca_issuer) = self_issued_realm_ca(); - - let provider_id = FfiKeyPair::generate(); - // Distinct from caller_id -- call_direct_with_cert_chain dials the - // resolved station in a FRESH connection using this identity while - // caller_id's own resolver session stays open throughout; reusing one - // identity for both was a real bug (this fleet kicks whichever - // connection reuses an identity second), already found and fixed in - // live_ffi.rs's streaming test after hitting the identical symptom - // ("timed out waiting for a frame" regardless of how long the - // timeout was) there first. - let dial_id = FfiKeyPair::generate(); - let caller_id = FfiKeyPair::generate(); - let procedure = format!( - "macula_rust_ffi.live_test.cert_chain.{}", - short_hex(&provider_id.node_id()) - ); - let realm = vec![0u8; 32]; - let org = "live-ffi-test-org"; - - let leaf_der = issue_leaf(&ca_issuer, provider_id.node_id().try_into().unwrap(), org); - let cert_chain_pem = pem_bundle(&[leaf_der]); - - let provider = FfiSession::connect( - STATION_HOST.to_string(), - STATION_PORT, - FfiTrust::WebPki, - &provider_id, - ) - .await - .expect("provider connect"); - provider - .advertise_direct_with_cert_chain( - procedure.clone(), - realm.clone(), - 60_000, - cert_chain_pem, - &provider_id, - ) - .await - .expect("advertise_direct_with_cert_chain"); - - let serve_procedure = procedure.clone(); - let serve_task = tokio::spawn(async move { - let result = - serve_until_procedure(&provider, &serve_procedure, 10_000, 10, &provider_id).await; - // Keep the session alive briefly after the last reply -- Session - // has no Drop impl, so dropping it immediately on return can - // close the underlying QUIC connection before the just-sent - // reply frame actually reaches the peer. Same race already - // documented on Session::close, same fix already confirmed live - // for the identical symptom in serve_one_call_gated (see 986b981). - tokio::time::sleep(Duration::from_millis(300)).await; - result - }); - - let caller = FfiSession::connect( - STATION_HOST.to_string(), - STATION_PORT, - FfiTrust::WebPki, - &caller_id, - ) - .await - .expect("caller connect"); - - // Positive: right org, chain validates, real RESULT comes back. - let response = match caller - .call_direct_with_cert_chain( - procedure.clone(), - realm.clone(), - ca_pem.clone(), - org.to_string(), - FfiValue::Text("authorized via cert chain".to_string()), - 30_000, - &dial_id, - ) - .await - { - Ok(r) => r, - // KNOWN EXTERNAL BLOCKER, not a defect here -- same - // already-documented gap as live_ffi.rs's streaming/content test - // and the core crate's own tests/live_direct_dial_extensions.rs: - // the demo fleet's station_endpoint records expire (5min TTL) - // faster than they're republished. - Err(FfiError::Resolve { reason }) if reason.contains("no reachable station_endpoint") => { - eprintln!( - "SKIP: resolved station published no reachable station_endpoint -- known \ - external fleet staleness, not a defect here: {reason}" - ); - serve_task.abort(); - return; - } - // RESOLVED 2026-08-30: the "provider answers correctly, caller - // never gets the reply" symptom that took 3 rounds of theories to - // narrow (see git history on this file for the ruled-out ones -- - // not cert-chain-specific, not station-specific, not a - // timeout-cancellation artifact) turned out to be the exact same - // premature-Session-drop race confirmed and fixed for - // serve_one_call_gated in 986b981: `serve_task`'s async block - // dropped `provider` the instant `serve_until_procedure` returned, - // and Session has no Drop impl, so the just-sent reply frame could - // be torn down before it reached the peer. Fixed above by keeping - // the session alive 300ms after the last reply. Verified with 5 - // consecutive clean passes (was failing reliably before). - Err(e) => panic!("call_direct_with_cert_chain: {e}"), - }; - match response { - FfiCallResponse::Result { payload, .. } => { - assert_eq!( - payload, - FfiValue::Text("authorized via cert chain".to_string()) - ); - } - FfiCallResponse::Error { - code, name, detail, .. - } => { - panic!("expected a real RESULT, got ERROR code={code} name={name} detail={detail:?}"); - } - } - serve_task - .await - .expect("serve task should not panic") - .expect("serve_one_call should have answered the call cleanly"); - - // Negative control: same chain, wrong expected org -> resolve itself - // must fail (nothing to dial), not silently succeed. - let resolver = FfiSession::connect( - STATION_HOST.to_string(), - STATION_PORT, - FfiTrust::WebPki, - &caller_id, - ) - .await - .expect("resolver connect for negative control"); - let err = resolver - .resolve_direct_with_cert_chain( - procedure, - realm, - ca_pem, - "some-other-org".to_string(), - &caller_id, - ) - .await - .expect_err("resolve_direct_with_cert_chain should reject the wrong org"); - match err { - FfiError::Resolve { reason } => { - assert!( - reason.contains("Authorized") || reason.contains("authoriz"), - "expected an authorization-shaped rejection reason, got: {reason}" - ); - } - other => panic!("expected FfiError::Resolve, got {other:?}"), - } -} diff --git a/macula-rust-ffi/tests/live_ffi.rs b/macula-rust-ffi/tests/live_ffi.rs deleted file mode 100644 index 82ba57b..0000000 --- a/macula-rust-ffi/tests/live_ffi.rs +++ /dev/null @@ -1,758 +0,0 @@ -//! Integration tests exercising the actual UniFFI-exported surface -//! (`FfiSession`/`FfiKeyPair`/`FfiValue`/`FfiCallHandler`), not the core -//! crate directly — this is what a generated Kotlin/Swift binding would -//! actually call through. `macula-rust-ffi` had no test harness at -//! all beyond `FfiValue`'s own pure conversion tests before this file; -//! testing only the core crate (already covered by `../tests/live_station.rs`) -//! would never catch a bug introduced in THIS crate's own wrapping — -//! wrong argument order, a broken error conversion, a type that doesn't -//! actually cross the boundary the way it's assumed to. -//! -//! **Not run by default CI** — every test here is `#[ignore]`d, matching -//! `../tests/live_station.rs`'s own convention: -//! -//! ```text -//! cargo test -p macula-rust-ffi --test live_ffi -- --ignored --nocapture -//! ``` -//! -//! No mobile toolchain (Kotlin/Swift compiler + runtime) is available in -//! this environment, so this cannot exercise the generated bindings -//! themselves end-to-end — only the Rust-side glue every generated binding -//! calls into. `cargo run -p macula-rust-ffi --bin uniffi-bindgen -- -//! generate --library --language kotlin --out-dir

` -//! succeeding without error (checked separately, not in this file) is the -//! remaining piece of confidence that the newly-added types/methods are -//! actually representable in the generated bindings at all. - -use macula_rust_ffi::{ - ucan_create, FfiCallHandler, FfiCallResponse, FfiCapability, FfiError, FfiKeyPair, FfiPolicy, - FfiSession, FfiStreamMode, FfiTrust, FfiValue, -}; -use std::sync::Arc; - -const MILAN_HOST: &str = "station-it-milan.macula.io"; -const MILAN_PORT: u16 = 4433; - -/// A short, unique-enough procedure-name suffix from an identity's node -/// id, without pulling in a `hex` crate dependency just for test naming. -fn short_hex(bytes: &[u8]) -> String { - bytes.iter().take(8).map(|b| format!("{b:02x}")).collect() -} - -struct EchoHandler; - -#[async_trait::async_trait] -impl FfiCallHandler for EchoHandler { - async fn handle( - &self, - _procedure: String, - _realm: Vec, - payload: FfiValue, - ) -> Result { - Ok(payload) - } -} - -/// Records the procedure of every CALL it answers, so a test can confirm -/// `serve_one_call`/`serve_one_call_gated` actually answered ITS intended -/// call and not some other inbound CALL that happened to route to this -/// connection first — this fleet is a real, shared, multi-tenant public -/// station, and `serve_one_call`'s own doc is explicit that ANY inbound -/// CALL frame that arrives is served (the FFI `FfiCallHandler` trait -/// receives every procedure unconditionally, doing its own routing inside -/// `handle` — see this crate's module doc) — a one-shot serve is not -/// guaranteed to be answering the call a test is waiting on. -struct RecordingEchoHandler { - served: Arc>>, -} - -#[async_trait::async_trait] -impl FfiCallHandler for RecordingEchoHandler { - async fn handle( - &self, - procedure: String, - _realm: Vec, - payload: FfiValue, - ) -> Result { - self.served.lock().await.push(procedure); - Ok(payload) - } -} - -/// Loops `serve_one_call_gated` until it answers a CALL for `procedure` -/// specifically (see [`RecordingEchoHandler`]'s own doc for why a single -/// call to `serve_one_call_gated` isn't sufficient on this shared fleet), -/// or `max_attempts` attempts are exhausted. A per-attempt timeout -/// (`FfiError::Recv`, covering both `ServeCallError::Timeout` and a -/// generic recv failure) is NOT fatal here — it just means nothing arrived -/// that round, so the loop tries again; only a non-`Recv` error (e.g. a -/// reply actually failed to SEND) aborts early, since that indicates a -/// real problem rather than "nothing showed up yet." -async fn serve_until_procedure( - session: &FfiSession, - procedure: &str, - policy: FfiPolicy, - per_attempt_timeout_ms: u64, - max_attempts: u32, - identity: &FfiKeyPair, -) -> Result<(), FfiError> { - let served = Arc::new(tokio::sync::Mutex::new(Vec::new())); - for attempt in 0..max_attempts { - let handler = Arc::new(RecordingEchoHandler { - served: Arc::clone(&served), - }); - match session - .serve_one_call_gated(handler, policy.clone(), per_attempt_timeout_ms, identity) - .await - { - Ok(()) => { - if served.lock().await.iter().any(|p| p == procedure) { - return Ok(()); - } - } - Err(FfiError::Recv { reason }) => { - eprintln!( - "serve_until_procedure: attempt {attempt} got no CALL ({reason}), retrying" - ); - } - Err(other) => return Err(other), - } - } - Ok(()) -} - -/// Proves the newly-exposed `resolve_direct`/`call_direct`/`advertise_direct` -/// work end-to-end THROUGH the FFI types: a provider session advertises -/// direct-dial reachability and serves one call via the exported -/// `FfiCallHandler` trait; a separate session/identity resolves and calls -/// it, and gets back a real RESULT it can inspect via `FfiValue`/ -/// `FfiCallResponse` — not just "reached the call stage" (see this -/// session's own `macula-go`/`macula-rust` history for why that -/// weaker bar isn't good enough: it already hid a real -/// missing-plain-ADVERTISE bug in `advertise_direct` once). -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn direct_dial_round_trip_through_the_ffi_surface() { - let provider_id = FfiKeyPair::generate(); - let caller_id = FfiKeyPair::generate(); - let procedure = format!( - "macula_rust_ffi.live_test.echo.{}", - short_hex(&provider_id.node_id()) - ); - let realm = vec![0u8; 32]; - - let provider = Arc::new( - FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &provider_id, - ) - .await - .expect("provider connect"), - ); - - provider - .advertise_direct(procedure.clone(), realm.clone(), 60_000, &provider_id) - .await - .expect("advertise_direct"); - - let serve_provider = Arc::clone(&provider); - let serve_task = tokio::spawn(async move { - serve_provider - .serve_one_call(Arc::new(EchoHandler), 20_000, &provider_id) - .await - }); - - let caller = FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &caller_id, - ) - .await - .expect("caller connect"); - - let response = caller - .call_direct( - procedure, - realm, - FfiValue::Text("hello via ffi direct-dial".to_string()), - 15_000, - &caller_id, - ) - .await; - // KNOWN EXTERNAL BLOCKER, not a defect here -- the demo fleet's - // station_endpoint records expire (5min TTL) faster than they're - // republished; confirmed repeatedly this session across go-sdk and - // rust-sdk, core crate and FFI alike. - let response = match response { - Ok(r) => r, - Err(FfiError::Resolve { reason }) if reason.contains("no reachable station_endpoint") => { - eprintln!( - "SKIP: resolved station published no reachable station_endpoint -- known \ - external fleet staleness, not a defect here: {reason}" - ); - serve_task.abort(); - return; - } - Err(e) => panic!("call_direct should succeed through a live provider: {e}"), - }; - - match response { - FfiCallResponse::Result { payload, .. } => { - assert_eq!( - payload, - FfiValue::Text("hello via ffi direct-dial".to_string()), - "echoed payload should round-trip through FfiValue unchanged" - ); - } - FfiCallResponse::Error { - code, name, detail, .. - } => { - panic!("expected a real RESULT, got a bolt4 ERROR frame instead: code={code} name={name} detail={detail:?}"); - } - } - - serve_task - .await - .expect("serve task should not panic") - .expect("serve_one_call should have answered the call cleanly"); -} - -/// `resolve_direct` alone, without a live call — proves the DHT -/// publish/resolve round trip through the FFI's `FfiResolved` type -/// specifically (the round trip through `FfiCallResponse`/`FfiValue` is -/// already covered above). -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn resolve_direct_through_the_ffi_surface() { - let id = FfiKeyPair::generate(); - let procedure = format!( - "macula_rust_ffi.live_test.resolve_only.{}", - short_hex(&id.node_id()) - ); - let realm = vec![0u8; 32]; - - let session = FfiSession::connect(MILAN_HOST.to_string(), MILAN_PORT, FfiTrust::WebPki, &id) - .await - .expect("connect"); - - session - .advertise_direct(procedure.clone(), realm.clone(), 60_000, &id) - .await - .expect("advertise_direct"); - - let resolved = match session.resolve_direct(procedure, realm, &id).await { - Ok(r) => r, - // KNOWN EXTERNAL BLOCKER, not a defect here -- see the identical - // handling and comment in direct_dial_round_trip_through_the_ffi_surface - // above. - Err(FfiError::Resolve { reason }) if reason.contains("no reachable station_endpoint") => { - eprintln!( - "SKIP: resolved station published no reachable station_endpoint -- known \ - external fleet staleness, not a defect here: {reason}" - ); - return; - } - Err(e) => panic!("resolve_direct should find what was just advertised: {e}"), - }; - - // `resolved.station` is the STATION's own node id (Milan's, here) -- - // the DHT record's `serving_station` field, per `advertise_direct`'s - // own design -- NOT this identity's node id. `FfiSession` exposes no - // accessor for "which station am I connected to" to compare against - // directly, so a 32-byte sanity check is the honest bound here; the - // full round trip above already proves resolution correctly finds a - // station that actually routes the call, which is the real property - // under test. - assert_eq!(resolved.station.len(), 32, "station id should be 32 bytes"); - assert!( - !resolved.host.is_empty(), - "resolved host should be non-empty" - ); - assert_eq!(resolved.port, 4433); -} - -/// Proves `serve_one_call_gated`/`FfiPolicy`/`call_with_ucan` work -/// end-to-end THROUGH the FFI surface: a caller with no token is refused -/// (`Unauthorized`) before the handler ever runs; the same caller with a -/// valid token, minted via the exported `ucan_create`, reaches it and gets -/// a real RESULT. No live UCAN-gated procedure exists anywhere in this -/// workspace to test against (confirmed this session, both languages) — -/// this test proves the MECHANISM works for real, with its own throwaway -/// procedure and issuer, the same way `live_cert_chain.rs`'s self-issued -/// trust anchor proves cert-chain verification without needing fleet -/// provisioning. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn ucan_gated_serve_one_call_through_the_ffi_surface() { - let issuer_id = FfiKeyPair::generate(); - - // 1. Unauthorized: no token at all. Independent identities/procedure - // from case 2 below -- nothing needs to be shared between the two - // cases, and keeping them separate avoids needing to clone an - // FfiKeyPair (an opaque uniffi::Object, not exposed as Clone). - { - let provider_id = FfiKeyPair::generate(); - let caller_id = FfiKeyPair::generate(); - let procedure = format!( - "macula_rust_ffi.live_test.ucan_gated.unauthorized.{}", - short_hex(&provider_id.node_id()) - ); - let realm = vec![0u8; 32]; - let policy = FfiPolicy::Required { - issuer: issuer_id.node_id(), - }; - - let provider = FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &provider_id, - ) - .await - .expect("provider connect (unauthorized case)"); - provider - .advertise(procedure.clone(), realm.clone(), &provider_id) - .await - .expect("advertise (unauthorized case)"); - - let serve_procedure = procedure.clone(); - let serve = tokio::spawn(async move { - serve_until_procedure(&provider, &serve_procedure, policy, 10_000, 5, &provider_id) - .await - }); - - let caller = FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &caller_id, - ) - .await - .expect("caller connect (unauthorized case)"); - let call_result = caller - .call(procedure, realm, FfiValue::Null, 25_000, &caller_id) - .await; - serve - .await - .expect("serve task should not panic") - .expect("serve_until_procedure should not itself error"); - match call_result.expect("call should complete at the wire level even when refused") { - FfiCallResponse::Error { code, .. } => { - assert_eq!(code, 0x10, "expected BOLT#4 unauthorized (0x10)"); - } - other => panic!("expected an Unauthorized ERROR frame, got {other:?}"), - } - } - - // 2. Authorized: a valid token from the required issuer reaches the handler. - let provider_id = FfiKeyPair::generate(); - let caller_id = FfiKeyPair::generate(); - let procedure = format!( - "macula_rust_ffi.live_test.ucan_gated.authorized.{}", - short_hex(&provider_id.node_id()) - ); - let realm = vec![0u8; 32]; - let policy = FfiPolicy::Required { - issuer: issuer_id.node_id(), - }; - - let provider = FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &provider_id, - ) - .await - .expect("provider connect (authorized case)"); - provider - .advertise(procedure.clone(), realm.clone(), &provider_id) - .await - .expect("advertise (authorized case)"); - - let serve_procedure = procedure.clone(); - let serve_task = tokio::spawn(async move { - serve_until_procedure(&provider, &serve_procedure, policy, 10_000, 5, &provider_id).await - }); - - // A gated provider accepts a token only from the caller it names as its - // audience: that caller's node id as lowercase hex. - let caller_audience: String = caller_id - .node_id() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect(); - let token = ucan_create( - "did:macula:live-test-issuer".to_string(), - caller_audience, - vec![FfiCapability { - with: "mri:test".to_string(), - can: "invoke".to_string(), - }], - &issuer_id, - None, - None, - ) - .expect("ucan_create"); - - let caller = FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &caller_id, - ) - .await - .expect("caller connect (authorized case)"); - let call_result = caller - .call_with_ucan( - procedure, - realm, - FfiValue::Text("authorized via ucan".to_string()), - 25_000, - &caller_id, - token, - ) - .await; - - // KNOWN EXTERNAL BLOCKER, confirmed 2026-08-30, not a defect in this - // crate or its FFI wrapper: isolated via a core-crate-only diagnostic - // (bypassing this FFI entirely) that reproduces the identical failure - // -- the REAL, currently-deployed macula-station actively closes the - // connection the instant it receives a CALL frame carrying a - // non-empty `ucan_token` field (case 1 above, an EMPTY token, works - // fine; this is specifically about a real, non-empty one). The client - // side (this crate, and macula-go's equivalent) sends exactly - // what the wire protocol plan documents; the station itself was never - // updated to tolerate the field. UCAN support was built and - // unit-tested in both SDKs this session but this is the first attempt - // to exercise it against the real fleet, and it surfaced a real, - // previously-unknown cross-cutting gap at the STATION layer (a - // separate repo) -- out of scope to fix from here. Treated as a soft - // pass with a loud message rather than a hard failure so this test - // keeps proving the CLIENT-side mechanism is correct (case 1 above) - // while staying an honest regression guard for the day the station - // side is fixed -- flip this back to a hard `.expect()` once that - // lands, the same way other known-external-blocker tests in this - // codebase are written to be re-tightened later. - let response = match call_result { - Ok(r) => r, - Err(e) => { - eprintln!( - "SKIPPING assertion: call_with_ucan failed, which matches a KNOWN external \ - station-side gap (real macula-station closes the connection on a non-empty \ - ucan_token field -- confirmed via a core-crate-only diagnostic, not a bug in \ - this crate): {e}" - ); - serve_task.await.ok(); - return; - } - }; - match response { - FfiCallResponse::Result { payload, .. } => { - assert_eq!(payload, FfiValue::Text("authorized via ucan".to_string())); - } - FfiCallResponse::Error { - code, name, detail, .. - } => panic!("expected a real RESULT, got ERROR code={code} name={name} detail={detail:?}"), - } - serve_task - .await - .expect("serve task should not panic") - .expect("serve_one_call_gated should have answered the authorized call"); -} - -/// Proves `run_publisher` genuinely publishes `pubsub.publish_started_v1`/ -/// `pubsub.publish_completed_v1` around a real publish — confirmed by an -/// INDEPENDENT third session/identity subscribed before the publish -/// happens, not the publisher's own bookkeeping, the same discipline the -/// core crate's own `run_subscriber_and_run_publisher_against_the_real_fleet` -/// test already established. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn run_publisher_facts_through_the_ffi_surface() { - let publisher_id = FfiKeyPair::generate(); - let watcher_id = FfiKeyPair::generate(); - let topic = format!( - "macula_rust_ffi.live_test.pubsub.{}", - short_hex(&publisher_id.node_id()) - ); - let realm = vec![0u8; 32]; - - let watcher = FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &watcher_id, - ) - .await - .expect("watcher connect"); - // run_publisher's auto-facts go to FIXED topic names - // (pubsub.publish_started_v1/pubsub.publish_completed_v1), not the - // per-publish `topic` itself -- subscribe to those, not `topic`. These - // are global on this shared public fleet (nothing in the exposed FFI - // surface returns run_publisher's internal publish_id to correlate - // against), so this test can only confirm AT LEAST ONE of each - // landed, not that it was specifically ours -- an honest limit of - // what's observable through the exposed API, not a gap in the test. - let started = watcher - .subscribe( - "pubsub.publish_started_v1".to_string(), - realm.clone(), - &watcher_id, - ) - .await - .expect("watcher subscribe (started)"); - let completed = watcher - .subscribe( - "pubsub.publish_completed_v1".to_string(), - realm.clone(), - &watcher_id, - ) - .await - .expect("watcher subscribe (completed)"); - - let publisher = FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &publisher_id, - ) - .await - .expect("publisher connect"); - publisher - .run_publisher( - topic, - realm, - 1, - FfiValue::Text("payload".to_string()), - 0, - true, - &publisher_id, - ) - .await - .expect("run_publisher"); - - // Each subscription receives only its own fact topic, so the first event - // on each is a fact of that kind. These topics are global on this shared - // public fleet, so the test confirms one of each landed, as noted above. - let saw_started = started - .recv_event(10_000) - .await - .is_ok_and(|event| event.topic.ends_with("pubsub.publish_started_v1")); - let saw_completed = completed - .recv_event(10_000) - .await - .is_ok_and(|event| event.topic.ends_with("pubsub.publish_completed_v1")); - started.close().await; - completed.close().await; - assert!(saw_started, "pubsub.publish_started_v1 should have landed"); - assert!( - saw_completed, - "pubsub.publish_completed_v1 should have landed" - ); -} - -/// Proves `open_stream_direct` and `put_direct`/`get_direct` all work -/// end-to-end THROUGH the FFI surface. Streaming: a real chunk sent -/// through a direct-dial-opened stream arrives at the other end. Content: -/// a real byte-exact put/get round trip through a resolved direct-dial -/// connection. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn streaming_and_content_direct_dial_through_the_ffi_surface() { - // --- streaming --- - let provider_id = FfiKeyPair::generate(); - // Two DISTINCT identities on the caller side: `resolver_id` for the - // `caller` session (used only to query the DHT and stays open the - // whole test), `dial_id` for open_stream_direct's own dial. Under one - // identity for both, open_stream_direct would run the stream on the - // caller session instead of dialing, and this test covers the dial. - let resolver_id = FfiKeyPair::generate(); - let dial_id = FfiKeyPair::generate(); - let procedure = format!( - "macula_rust_ffi.live_test.stream_direct.{}", - short_hex(&provider_id.node_id()) - ); - let realm = vec![0u8; 32]; - - // Arc, not a bare value moved into the spawned task below: the task - // does ONLY accept() (returning the accepted stream, not consuming - // it), so `provider` stays alive here in the outer scope too, past - // send_data, until an explicit close at the very end. An earlier - // draft did accept+send_data both inside the spawned task and let - // `provider` drop implicitly the moment that task returned -- real - // bug, reproduced live (3/3 on one station): the caller saw - // Recv("peer closed the stream") instead of the pushed data, because - // `send_data` succeeding only means the data was handed to quinn's - // send-scheduling machinery, not that it reached the peer -- the - // implicit drop tore the connection down before delivery was actually - // confirmed. This is the EXACT gotcha already documented in the core - // crate's own tests/live_direct_dial_extensions.rs (which structures - // this correctly) and in Session::close's own doc -- read both, and - // still wrote the bug into this FFI-level test's first draft, so - // spelling it out here for the next person editing this file. - let provider = Arc::new( - FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &provider_id, - ) - .await - .expect("provider connect"), - ); - provider - .advertise_direct(procedure.clone(), realm.clone(), 60_000, &provider_id) - .await - .expect("advertise_direct"); - - // accept_stream, like serve_one_call, accepts the NEXT inbound - // dedicated stream unconditionally -- it doesn't filter by procedure - // (see RecordingEchoHandler's own doc for the identical reasoning on - // the unary-call side). On this shared public fleet a stray unrelated - // stream-open can arrive first; loop past it via `info.procedure` - // rather than assuming the first accept is this test's own. - let accept_procedure = procedure.clone(); - let accept_provider = Arc::clone(&provider); - let accept_task = tokio::spawn(async move { - for _ in 0..20 { - let accepted = accept_provider.accept_stream(20_000).await?; - if accepted.info.procedure == accept_procedure { - return Ok(accepted); - } - eprintln!( - "accept_stream: got a stream for {:?}, not ours ({accept_procedure:?}); retrying", - accepted.info.procedure - ); - } - Err(FfiError::Closed) - }); - - let caller = FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &resolver_id, - ) - .await - .expect("caller (resolver) connect"); - let opened = match caller - .open_stream_direct( - procedure, - realm, - FfiStreamMode::ServerStream, - FfiValue::Null, - 15_000, - &dial_id, - ) - .await - { - Ok(o) => o, - // KNOWN EXTERNAL BLOCKER, not a defect here -- matches the core - // crate's own tests/live_direct_dial_extensions.rs handling of - // the identical ResolveError::StationEndpointNotFound: the - // demo fleet's station_endpoint records expire (5min TTL) faster - // than they're republished, confirmed repeatedly this session - // across both go-sdk and rust-sdk, core crate and FFI alike. - Err(FfiError::Resolve { reason }) if reason.contains("no reachable station_endpoint") => { - eprintln!( - "SKIP: resolved station published no reachable station_endpoint -- known \ - external fleet staleness, not a defect here: {reason}" - ); - accept_task.abort(); - return; - } - Err(e) => panic!("open_stream_direct: {e}"), - }; - let accepted = accept_task - .await - .expect("accept task should not panic") - .expect("provider should accept the direct-dial-opened stream"); - accepted - .stream - .send_data( - macula_rust_ffi::FfiStreamEncoding::Raw, - FfiValue::Text("direct stream data".to_string()), - &provider_id, - ) - .await - .expect("provider should push the chunk"); - let recv_result = opened.stream.recv(15_000).await; - let item = recv_result.expect("recv on direct-dial-opened stream"); - match item { - macula_rust_ffi::FfiStreamItem::Data { body, .. } => { - assert_eq!(body, FfiValue::Text("direct stream data".to_string())); - } - other => panic!("expected Data, got {other:?}"), - } - opened.lease.release(&dial_id).await; - - // --- content --- - let resolve_via_id = FfiKeyPair::generate(); - // A SEPARATE identity from resolve_via_id for the put/get calls - // themselves -- put_direct's own doc warns that reusing resolve_via's - // identity risks this fleet's one-connection-per-identity guard - // kicking resolve_via's own session out from under the caller, since - // put_direct's internal dial would otherwise reuse the identity that's - // ALSO holding resolve_via's connection open (the same identity- - // collision class already found and fixed elsewhere this session). - let content_id = FfiKeyPair::generate(); - let session = FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &resolve_via_id, - ) - .await - .expect("content session connect"); - let station = session.station_id().await; - let data = b"direct-dial content transfer test payload".to_vec(); - let mcid = session - .put_direct( - station, - data.clone(), - "live-test-blob".to_string(), - 15_000, - &content_id, - ) - .await - .expect("put_direct"); - // KNOWN REAL GAP, discovered live 2026-08-30, NOT fixed here - // (deliberately -- this is a core-crate architectural question, out - // of scope for an FFI-exposure pass): `get_direct` resolves via a - // `content_announcement` DHT record that `macula.erl`'s own doc says - // the STATION publishes automatically on receiving content -- but - // `dht::new_content_announcement` (the only code in this crate that - // COULD build one) has zero callers anywhere in `src/`, confirmed by - // grep, and no client-facing "announce content direct" is exposed on - // purpose (a leaf identity architecturally can't pass the trust check - // for one, per this crate's own doc on that decision). Whether the - // REAL deployed station actually performs this auto-announcement at - // all, or does so on a much longer timescale than tested here, is - // unconfirmed -- 10 retries x 500ms found nothing. Practical effect: - // `get_direct` currently cannot succeed for content stored via - // `put_direct`, in this environment, within this window. Retry a - // bounded number of times (in case it's genuinely just slow) and - // treat a persistent miss as a documented gap, not a hard test - // failure, so this test still proves `put_direct` itself works - // (confirmed above) without permanently blocking on an - // architectural question this pass isn't scoped to resolve. - for _ in 0..10 { - match session.get_direct(mcid.clone(), 15_000, &content_id).await { - Ok(bytes) => { - assert_eq!(bytes, data, "content should round-trip byte-exact"); - return; - } - Err(FfiError::Content { reason }) if reason.contains("no verifiable announcement") => { - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - } - Err(e) => panic!("get_direct: {e}"), - } - } - eprintln!( - "SKIP: get_direct found no content_announcement for this mcid after 10 retries -- \ - known real gap (see comment above), not a defect in this FFI pass" - ); -} diff --git a/macula-rust-ffi/tests/pool_ffi.rs b/macula-rust-ffi/tests/pool_ffi.rs new file mode 100644 index 0000000..f8de07c --- /dev/null +++ b/macula-rust-ffi/tests/pool_ffi.rs @@ -0,0 +1,360 @@ +//! The mobile bindings over the pool, as Kotlin and Swift reach them, +//! against macula-go's in-process stations (the core crate's +//! tests/common/lab.rs): node keys, a pool, calls to a handler the foreign +//! side implements, pubsub, streams, and records. + +#[path = "../../tests/common/mod.rs"] +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use common::lab::{Lab, LabStation}; +use macula_rust::profile::Profile; +use macula_rust_ffi::{ + own_procedure, FfiCallHandler, FfiError, FfiNodeKey, FfiPool, FfiPoolOptions, FfiProfile, + FfiRealmKey, FfiRequest, FfiSeed, FfiStream, FfiStreamEvent, FfiStreamHandler, FfiStreamMode, + FfiValue, +}; + +const ORG_PROCEDURE: &str = "mcl-echo/echo"; +const TOPIC: &str = "mcl-rust/ffi/greeting_sent_v1"; + +fn seed(s: &LabStation) -> FfiSeed { + FfiSeed { + host: s.host.clone(), + port: s.port, + node_id: s.node_id.to_vec(), + } +} + +fn options() -> FfiPoolOptions { + FfiPoolOptions { + respawn_delay_ms: 100, + connect_timeout_ms: 15_000, + ..FfiPoolOptions::default() + } +} + +async fn pool(key: &Arc, s: &LabStation, opts: FfiPoolOptions) -> Arc { + FfiPool::connect(key.clone(), vec![seed(s)], opts) + .await + .unwrap() +} + +/// A foreign handler: echoes, or refuses "fail". +struct Echo; + +#[async_trait::async_trait] +impl FfiCallHandler for Echo { + async fn handle(&self, request: FfiRequest) -> Result { + if request.payload == FfiValue::Text("fail".into()) { + return Err(FfiError::Handler { + message: "refused by the handler".into(), + }); + } + Ok(FfiValue::Fields(vec![ + macula_rust_ffi::FfiMapEntry { + key: FfiValue::Text("echo".into()), + value: request.payload, + }, + macula_rust_ffi::FfiMapEntry { + key: FfiValue::Text("caller".into()), + value: FfiValue::Bytes(request.caller), + }, + ])) + } +} + +/// A foreign stream handler: sends "a" then "b". +struct Chunks; + +#[async_trait::async_trait] +impl FfiStreamHandler for Chunks { + async fn handle(&self, stream: Arc) -> Result<(), FfiError> { + for chunk in ["a", "b"] { + stream.send(chunk.as_bytes().to_vec()).await?; + } + Ok(()) + } +} + +#[test] +fn a_node_key_is_made_in_either_profile_and_survives_its_key_file() { + let dir = tempfile::tempdir().unwrap(); + for profile in [FfiProfile::PqPure, FfiProfile::PqHybrid] { + let key = FfiNodeKey::generate(profile).unwrap(); + assert_eq!(key.profile(), profile); + assert_eq!(key.node_id().len(), 32); + assert_eq!(key.node_id()[0], 0, "the puzzle's leading zero bits"); + let path = dir.path().join(format!("{profile:?}.key")); + key.save(path.to_string_lossy().into()).unwrap(); + let loaded = FfiNodeKey::load(path.to_string_lossy().into(), profile).unwrap(); + assert_eq!(loaded.node_id(), key.node_id()); + } + let missing = FfiNodeKey::load( + dir.path().join("none").to_string_lossy().into(), + FfiProfile::PqPure, + ); + assert!(matches!(missing, Err(FfiError::Key { .. }))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_call_reaches_a_foreign_handler_in_the_provider_s_own_namespace() { + let lab = Lab::start(Profile::PqPure); + let (serving, callers) = (lab.station("ffi serving"), lab.station("ffi callers")); + lab.share(&[&serving, &callers]); + let realm = vec![0x42; 32]; + let provider_key = FfiNodeKey::generate(FfiProfile::PqPure).unwrap(); + let provider = pool(&provider_key, &serving, options()).await; + let ring = own_procedure(provider.node_id(), "ring".into()).unwrap(); + let served = provider + .serve(realm.clone(), ring.clone(), Arc::new(Echo)) + .await + .unwrap(); + + let caller_key = FfiNodeKey::generate(FfiProfile::PqPure).unwrap(); + let caller = pool(&caller_key, &callers, options()).await; + let answered = caller + .call( + realm.clone(), + ring.clone(), + FfiValue::Text("hi".into()), + None, + 5_000, + ) + .await + .unwrap(); + let FfiValue::Fields(fields) = answered else { + panic!("{answered:?}") + }; + assert_eq!(fields[0].value, FfiValue::Text("hi".into())); + assert_eq!(fields[1].value, FfiValue::Bytes(caller.node_id())); + + match caller + .call( + realm.clone(), + ring.clone(), + FfiValue::Text("fail".into()), + None, + 5_000, + ) + .await + { + Err(FfiError::Provider { code, detail, .. }) => { + assert_eq!(code, "handler_error"); + assert!(detail + .unwrap_or_default() + .contains("refused by the handler")); + } + other => panic!("{other:?}"), + } + let providers = caller.providers(realm.clone(), ring.clone()).await.unwrap(); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].node, provider.node_id()); + assert_eq!(providers[0].station, serving.node_id.to_vec()); + + served.stop().await.unwrap(); + // An org procedure in a realm the pool pins no key for is refused. + let unpinned = caller + .call(realm, ORG_PROCEDURE.into(), FfiValue::Null, None, 2_000) + .await; + assert!( + matches!(unpinned, Err(FfiError::NoRealmKey)), + "{unpinned:?}" + ); + caller.close().await; + provider.close().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_org_procedure_is_served_under_the_pinned_realm_key() { + let lab = Lab::start(Profile::PqHybrid); + let s = lab.station("ffi org"); + let realm = lab.realm("ffi-org", "mcl-echo"); + let trust = FfiPoolOptions { + realm_trust: vec![FfiRealmKey { + realm: realm.id.to_vec(), + key: realm.key.clone(), + }], + ..options() + }; + let provider_key = FfiNodeKey::generate(FfiProfile::PqHybrid).unwrap(); + let provider_id: [u8; 32] = provider_key.node_id().try_into().unwrap(); + lab.admit(&realm, &s, &[provider_id]); + let provider = pool(&provider_key, &s, trust.clone()).await; + provider + .serve(realm.id.to_vec(), ORG_PROCEDURE.into(), Arc::new(Echo)) + .await + .unwrap(); + let caller = pool( + &FfiNodeKey::generate(FfiProfile::PqHybrid).unwrap(), + &s, + trust, + ) + .await; + let answered = caller + .call( + realm.id.to_vec(), + ORG_PROCEDURE.into(), + FfiValue::Int(7), + None, + 5_000, + ) + .await + .unwrap(); + let FfiValue::Fields(fields) = answered else { + panic!("{answered:?}") + }; + assert_eq!(fields[0].value, FfiValue::Int(7)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_subscription_hears_a_publication_once() { + let lab = Lab::start(Profile::PqPure); + let s = lab.station("ffi pubsub"); + let realm = vec![5; 32]; + let listener = pool( + &FfiNodeKey::generate(FfiProfile::PqPure).unwrap(), + &s, + options(), + ) + .await; + let publisher = pool( + &FfiNodeKey::generate(FfiProfile::PqPure).unwrap(), + &s, + options(), + ) + .await; + let sub = listener + .subscribe(realm.clone(), TOPIC.into()) + .await + .unwrap(); + let me: [u8; 32] = listener.node_id().try_into().unwrap(); + let r: [u8; 32] = realm.clone().try_into().unwrap(); + for _ in 0..200 { + if lab.subscribed(&s, &me, &r, TOPIC) { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + publisher + .publish( + realm.clone(), + TOPIC.into(), + FfiValue::Text("hello".into()), + None, + ) + .await + .unwrap(); + let event = sub.next(5_000).await.unwrap().expect("the publication"); + assert_eq!(event.payload, FfiValue::Text("hello".into())); + assert_eq!(event.publisher, publisher.node_id()); + assert_eq!(event.topic, TOPIC); + assert!(sub.next(300).await.unwrap().is_none(), "heard once"); + sub.unsubscribe().await.unwrap(); + assert!(matches!(sub.next(100).await, Err(FfiError::Closed))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_stream_from_a_foreign_handler_is_heard_and_released() { + let lab = Lab::start(Profile::PqPure); + let (serving, callers) = ( + lab.station("ffi stream serving"), + lab.station("ffi stream callers"), + ); + lab.share(&[&serving, &callers]); + let realm = vec![6; 32]; + let provider = pool( + &FfiNodeKey::generate(FfiProfile::PqPure).unwrap(), + &serving, + options(), + ) + .await; + let watch = own_procedure(provider.node_id(), "watch".into()).unwrap(); + provider + .serve_stream( + realm.clone(), + watch.clone(), + FfiStreamMode::ServerStream, + Arc::new(Chunks), + ) + .await + .unwrap(); + let caller = pool( + &FfiNodeKey::generate(FfiProfile::PqPure).unwrap(), + &callers, + options(), + ) + .await; + let stream = caller + .open_stream( + realm, + watch, + FfiStreamMode::ServerStream, + FfiValue::Null, + None, + 0, + ) + .await + .unwrap(); + let mut got = Vec::new(); + loop { + match stream.recv(5_000).await.unwrap() { + FfiStreamEvent::Data { + body: FfiValue::Bytes(b), + .. + } => got.push(b), + FfiStreamEvent::End { .. } => break, + other => panic!("{other:?}"), + } + } + assert_eq!(got, vec![b"a".to_vec(), b"b".to_vec()]); + assert!(matches!( + stream.recv(1_000).await, + Err(FfiError::EndOfStream) + )); + for _ in 0..100 { + if lab.relayed(&serving) == 0 && lab.relayed(&callers) == 0 { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("the stream was not released within 2 s"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn records_go_through_the_pool() { + let lab = Lab::start(Profile::PqPure); + let s = lab.station("ffi records"); + let p = pool( + &FfiNodeKey::generate(FfiProfile::PqPure).unwrap(), + &s, + options(), + ) + .await; + let endpoints = p + .find_records_by_type(macula_rust::record::RecordType::STATION_ENDPOINT.0) + .await + .unwrap(); + assert_eq!(endpoints.len(), 1); + assert_eq!(endpoints[0].signer, s.node_id.to_vec()); + let missing = p.find_record(vec![0x22; 32]).await; + assert!( + matches!(missing, Err(FfiError::RecordNotFound)), + "{missing:?}" + ); + let refused = p.put_record(b"not a record".to_vec()).await; + assert!(refused.is_err()); + let bad_id = p.find_record(vec![1; 3]).await; + assert!( + matches!( + bad_id, + Err(FfiError::WrongByteLength { + expected: 32, + actual: 3 + }) + ), + "{bad_id:?}" + ); +} diff --git a/tests/common/lab.rs b/tests/common/lab.rs index f1ac7c8..5770c89 100644 --- a/tests/common/lab.rs +++ b/tests/common/lab.rs @@ -142,6 +142,10 @@ impl Lab { /// A realm named `name` with the org `org`. pub fn realm(&self, name: &str, org: &str) -> LabRealm { + assert!( + !name.contains(char::is_whitespace) && !org.contains(char::is_whitespace), + "a realm name or org with whitespace would split the lab's command: {name:?} {org:?}" + ); self.realm_line(&format!("realm {name} {org}")) } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index b9fa4ef..790858d 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -109,8 +109,18 @@ impl TestStations { /// naming how to build it: a test that cannot reach its stations proves /// nothing, so it is never skipped. fn spawn(args: &[&str]) -> (Child, ChildStdin, BufReader) { - let binary = std::env::var("MACULA_TESTSTATION") - .unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/target/teststation").to_string()); + // target/ of the workspace: this crate's own, or, for the FFI crate, its + // parent's. + let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let binary = std::env::var("MACULA_TESTSTATION").unwrap_or_else(|_| { + [manifest, manifest.parent().unwrap_or(manifest)] + .iter() + .map(|dir| dir.join("target/teststation")) + .find(|path| path.exists()) + .unwrap_or_else(|| manifest.join("target/teststation")) + .to_string_lossy() + .into_owned() + }); assert!( std::path::Path::new(&binary).exists(), "{binary} is missing: run scripts/build-teststation.sh first" From 39a580ef9aa141e8dd892ec2e0a04e8ebeb88813 Mon Sep 17 00:00:00 2001 From: beamologist Date: Sat, 26 Sep 2026 12:43:41 +0200 Subject: [PATCH 12/12] docs, CI and examples for 0.4.0 on the macula 12 wire - README rewritten for the macula 12 SDK: status, quick start, coming from 0.3, what is implemented, payloads, the mobile bindings, what is not yet here, testing. - CHANGELOG: both unreleased sections say what 0.4.0 and ffi-0.4.0 are. - Examples quickstart, serve and publish_subscribe, on the MACULA_* variables the other SDKs' examples read; serve and publish_subscribe run against a lab station. - NodeKey::load_or_create (and FfiNodeKey's): a new puzzle-solved key saved when nothing is at the path; anything there that does not load is refused and never replaced. - CI and both release workflows build the teststation (setup-go from its go.mod) before the tests, and test with --locked. - Cargo.lock is committed; .gitignore no longer hides it. - rust-version 1.89 (core) and 1.91 (ffi), the least the dependencies build with, checked with those toolchains. - plans/ removed: the 10.x wire spec and leak survey describe code that is gone. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 11 +- .github/workflows/release-core.yml | 26 +- .github/workflows/release-ffi.yml | 20 +- .gitignore | 1 - CHANGELOG.md | 261 +-- Cargo.lock | 2929 +++++++++++++++++++++++++ Cargo.toml | 2 +- README.md | 560 ++--- examples/common/mod.rs | 89 + examples/publish_subscribe.rs | 42 + examples/quickstart.rs | 35 + examples/serve.rs | 48 + macula-rust-ffi/Cargo.toml | 2 +- macula-rust-ffi/src/node_key.rs | 9 + macula-rust-ffi/tests/pool_ffi.rs | 4 + plans/PLAN_RESOURCE_LEAK_HARDENING.md | 293 --- plans/PLAN_WIRE_PROTOCOL.md | 1099 ---------- src/node_key/key_file.rs | 21 +- tests/identity_key_file.rs | 37 + 19 files changed, 3517 insertions(+), 1972 deletions(-) create mode 100644 Cargo.lock create mode 100644 examples/common/mod.rs create mode 100644 examples/publish_subscribe.rs create mode 100644 examples/quickstart.rs create mode 100644 examples/serve.rs delete mode 100644 plans/PLAN_RESOURCE_LEAK_HARDENING.md delete mode 100644 plans/PLAN_WIRE_PROTOCOL.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5985f6e..9eb3bc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: with: components: clippy - uses: Swatinem/rust-cache@v2 - - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + - run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings test: name: test @@ -37,7 +37,14 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - run: cargo test --workspace --all-features + # The integration tests dial macula-go's in-process stations + # (tests/teststation), built to target/teststation first. + - uses: actions/setup-go@v5 + with: + go-version-file: tests/teststation/go.mod + cache-dependency-path: tests/teststation/go.sum + - run: ./scripts/build-teststation.sh + - run: cargo test --workspace --all-features --locked doc: name: docs (deny broken links) diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 09e48de..f34d20b 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -24,18 +24,20 @@ jobs: exit 1 fi - # Rebuilds and re-runs the offline suite here rather than trusting - # an artifact from ci.yml's own run -- this workflow triggers off a - # tag push, a separate event from the branch push ci.yml already - # validated, so there's no prior run's output to reuse. Deliberately - # NOT --locked anywhere below: this repo doesn't commit Cargo.lock - # (library crate; a committed lock silently caps what a loose - # constraint resolves to on every later run -- see the workspace's - # own lockfile-hygiene convention), so a fresh checkout has no lock - # to be strict against. - - run: cargo build --workspace --all-targets - - run: cargo test --workspace --all-features - - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + # Rebuilds and re-runs the suite here rather than trusting an artifact + # from ci.yml's run: a tag push is a separate event from the branch + # push ci.yml validated, so there is no prior run to reuse. --locked: + # the committed Cargo.lock is what was tested. + # The integration tests dial macula-go's in-process stations + # (tests/teststation), built to target/teststation first. + - uses: actions/setup-go@v5 + with: + go-version-file: tests/teststation/go.mod + cache-dependency-path: tests/teststation/go.sum + - run: ./scripts/build-teststation.sh + - run: cargo build --workspace --all-targets --locked + - run: cargo test --workspace --all-features --locked + - run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings - run: cargo fmt --all -- --check # Dry-run needs no registry auth -- it only verifies metadata, diff --git a/.github/workflows/release-ffi.yml b/.github/workflows/release-ffi.yml index 981e81a..4758ffb 100644 --- a/.github/workflows/release-ffi.yml +++ b/.github/workflows/release-ffi.yml @@ -28,19 +28,25 @@ jobs: exit 1 fi - # Same reasoning as release-core.yml: rebuild+retest from scratch + # Same reasoning as release-core.yml: rebuild and retest from scratch # (a tag push is a separate event from the branch push ci.yml - # already validated), no --locked anywhere (no committed Cargo.lock - # in this repo). - - run: cargo build --workspace --all-targets - - run: cargo test --workspace --all-features - - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + # validated), against the committed Cargo.lock. + # The integration tests dial macula-go's in-process stations + # (tests/teststation), built to target/teststation first. + - uses: actions/setup-go@v5 + with: + go-version-file: tests/teststation/go.mod + cache-dependency-path: tests/teststation/go.sum + - run: ./scripts/build-teststation.sh + - run: cargo build --workspace --all-targets --locked + - run: cargo test --workspace --all-features --locked + - run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings - run: cargo fmt --all -- --check # Dry-run needs no registry auth -- it only verifies metadata, # compresses the package, and checks the result, never uploads. # This is also the step that proves macula-rust-ffi's `macula-rust - # = { path = "..", version = "0.2" }` dependency actually resolves + # = { path = "..", version = "0.4" }` dependency actually resolves # against the REAL published macula-rust on crates.io, not just the # local workspace path -- `cargo publish` downloads and rebuilds # against the registry version during verification, confirmed diff --git a/.gitignore b/.gitignore index 60db30f..c17da7f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ /target -Cargo.lock Cargo.lock.bak diff --git a/CHANGELOG.md b/CHANGELOG.md index fc53d63..04f985c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,164 +15,64 @@ usually touches both, but their version numbers don't move in lockstep. ### [0.4.0] - Unreleased +The macula 12 wire. **Breaking throughout**: a 0.3 node cannot reach a +macula 12 station, and nothing of the 0.3 API carries over. See the README's +"Coming from 0.3 and earlier". + +#### Added + +- `node_key::NodeKey`: macula 12 identities, ML-DSA-87 (`pq_pure`) or the + LAMPS composite id-MLDSA87-RSA4096-PSS-SHA512 (`pq_hybrid`, the fleet's), + RSA-PSS-4096 from aws-lc-rs. Node and key ids, the admission puzzle + (difficulty 8), owner-only key files in the seed form, the platform secure + store through `keystore::KeyStore`, and `load_or_create`. The composite is + held to the draft's own vector, and signatures crossed both ways with macula + 12.8.0 (`scripts/cross-verify-macula.sh`). +- `cbor`: macula 12's decoding rule v1 (depth 64, 131,072 elements, integers + within ±2^63, text or integer map keys, no duplicates, finite floats, `null` + the only simple value), with its refusal reasons. +- `binding`, `signed_object`: TLS and CONNECT bindings, status statements, and + signed and held objects. +- `transport`: QUIC on macula-pqc 0.3, ML-KEM hybrid key exchange with the + AES-256 Initial suite, every station pinned by its node_id through its TLS + binding. +- `handshake`: the v4 handshake (opener, challenge, CONNECT with its member + endorsement, HELLO, status). +- `frame`: version-2 frames: signed requests, replies, relay errors, + publications and stream frames, and neighbour signatures with their seq in + pq_hybrid. +- `record`: the DHT's records and storage keys, D25 authorization (a realm's + org directory and an org's delegation) and a node's own namespace + `~/`. +- `statement_issuer`: the CONNECT key bound to the identity key, its status + statement reissued every 15 minutes, and the key rotated every 5 days. +- `station_link::Link`: one station, ported from macula-go v0.12.0's + `stationlink`. Status statements both ways, a liveness probe, calls, the + DHT, pubsub with per-node dedup, serving with request admission, and + streaming sessions released on every path. +- `pool::Pool`: a node's links, ported from macula-go v0.12.0's `pool`. Pinned + seeds redialed and given back their subscriptions and served procedures. + Calls and streams reach a provider at its own station, trusted only under + the pinned realm key. Publications are signed once, and records go through + the pool. +- Tests against macula-go's in-process stations (`tests/teststation`, built by + `scripts/build-teststation.sh`, which CI runs), and `tests/live.rs` against + one real station, ignored unless asked. +- Examples: `quickstart`, `serve`, `publish_subscribe`. + +#### Removed + +- The 10.x wire and everything on it: `identity::KeyPair` (Ed25519), + `connection::Session` and its telemetry facts, the 10.x `frame`, `dht`, + `stream` and `pool`, `direct_dial`, `cert`, `cert_chain`, `ucan`, `bolt4`, + `content` and `manifest`, and WebPki trust. +- `plans/`: the 10.x wire spec and leak survey, which describe code that is + gone. + #### Changed -- **Breaking on the wire: every dial now uses POST-QUANTUM KEY EXCHANGE, and - nothing else.** Each TLS configuration starts from - [`macula-pqc`](https://crates.io/crates/macula-pqc) 0.1's `client_builder()`: - `SecP384r1MLKEM1024`, then `SecP256r1MLKEM768`, and no classical group, - where the crate used rustls's `ring` defaults (X25519, P-256, P-384, all - classical). A station on macula 11.5.0 or earlier offers only those and - **cannot be reached**; a station on macula's `macula-pqc` QUIC NIF - negotiates `SecP384r1MLKEM1024`. Every trust mode is covered, and - `PubkeyPinVerifier` and `SkipServerVerification` verify with the same - provider. Key exchange only: certificates are still classically signed. - -- **Direct dial tries every authorized provider.** `direct_dial::call`, - `call_with_ucan`, `call_with_cert_chain`, `open_stream_direct`, - `open_stream_direct_with_cert_chain` and `get_direct` try each advertised - provider in the order the DHT returns them, instead of only the first. A - provider that can't be reached before the request is sent is skipped for - the next one, and a request that has been sent is never sent again. - `get_direct` also retries while no provider has announced the content - yet, and a DHT lookup that fails is retried within the timeout instead of - ending the call. -- **Breaking: the timeout bounds the whole call**, finding the provider - included. A timeout sized for the request alone can now run out during - resolution. `resolve` and `resolve_with_cert_chain` give up after 10 - seconds, and `put_direct`'s timeout covers the endpoint lookup and the - dial. -- **Breaking: new `GetDirectError::Timeout { last }`.** It reports a - `get_direct` whose timeout ran out during a transfer, where `last`, also - its `source()`, carries the failure before it, or before any provider - lookup was answered. An exhaustive `match` on `GetDirectError` needs the - new arm. -- **Breaking: new `ResolveError::Timeout`, and a call reports what it - observed.** At its timeout a direct-dial call returns the last candidate - failure, else why an answered DHT lookup found nothing, else a failed - lookup's error, and `ResolveError::Timeout` only when nothing was - observed at all, where it used to report `ProcedureNotAdvertised` or - `StationEndpointNotFound`. A `station_endpoint` lookup follows the same - rule, reporting `StationEndpointNotFound` only when a lookup was - answered, and retries a lookup that fails within its budget. A record - that names no dialable address is looked up again too, and when it is - the latest answer the lookup reports the new - `ResolveError::MalformedStationEndpoint`. An exhaustive `match` on - `ResolveError` needs both new arms. -- **Breaking: direct dial reuses a session this process already has open - to the provider's station under the same identity.** A station keeps one - connection per identity and closes the older one when a newer one - arrives, so a second dial used to close `resolve_via` or a `Pool` link. - `open_stream_direct`, `open_stream_direct_with_cert_chain`, `put_direct` - and `get_direct` now run on that open session, on a dedicated QUIC - stream, and leave it open. The stream functions return - `direct_dial::OpenedStream` (`stream`, `lease`) instead of a - `(Session, StreamHandle)` tuple; release its `SessionLease` once the - stream is done. `call`, `call_with_ucan` and `call_with_cert_chain` run - on an open session the same way. -- **A direct call whose CALL was not sent tries the next candidate.** A call - whose session had ended, or whose turn to write didn't come in time, moves - on to the next candidate, and its station may be tried again on a later - pass. A call that was or may have been sent is returned as before. -- **A CALL handler receives its caller.** A map payload reaches the handler - with the caller's 32-byte node id under `"caller"`, the caller the CALL's - signature was verified against, replacing any `"caller"` the sender put in - the payload. A payload that isn't a map reaches the handler unchanged and - carries no caller. -- **Breaking: `StreamHandle::accept` refuses a STREAM_OPEN not signed by its - caller.** Stream handlers previously received the STREAM_OPEN's caller - field unverified; upgrade if a stream handler relies on it. A stream whose - first frame doesn't verify against the caller it names, has no signature - or caller, is of another type, or doesn't decode is aborted in both - directions with application error code 2 (`stream::REFUSED_STREAM`), - with nothing written, and accept waits for the next stream within its - timeout. `AcceptError::Parse` is removed. A provider that accepts a - stream and won't serve it refuses it with `StreamHandle::refuse`, which - writes a STREAM_ERROR, finishes the send half and stops reading with - code 2. -- **A stream handler receives its caller.** Map args of an accepted - STREAM_OPEN carry the verified caller under `"caller"`, replacing any - `"caller"` the opener put there, as a CALL handler's payload does. -- **Drop warnings.** A session logs a dropped CALL (`dropped_call`), a - RESULT or ERROR for no pending call (`dropped_reply`) and a refused stream - (`refused_stream_open`) with its `count`, `reason`, and `procedure` or - `call_id`. The first of a kind in an interval is logged at once, and the - rest are counted into one closing line when the interval ends. - `Session::set_drop_warning_interval` sets the interval, 60 seconds by - default. -- **A frame that doesn't decode ends a session as `SessionEndReason::Malformed`**, - where it used to end as `StreamFailed`. An exhaustive `match` on - `SessionEndReason` needs the new arm. -- **A session direct dial dialed is shared until its last request is done.** - A direct-dial request that finds it open uses it too, holding a lease of - its own, and the session closes when the last lease is released instead - of when the request that dialed it finishes. It is not reused once it is - closing. -- **Breaking: a UCAN-gated procedure binds the token to its caller.** - `ucan::Policy::check` takes the CALL's `caller` as well as its token, and - a `Policy::required` procedure accepts a token only when its `aud` is - that caller's 32-byte node id as lowercase hex, with no `did:` prefix. A - token with another or no audience is refused as `unauthorized` - (`UcanError::WrongAudience`, and `UcanError::NoCaller` when `check` gets - no 32-byte caller; both new). Mint tokens for gated procedures with that - audience. -- **An inbound CALL must be signed by the caller it names.** - `Session::serve_one_call` and `serve_one_call_gated` drop a CALL whose - signature doesn't verify against its `caller` field, without a reply and - before any policy or handler runs, matching the Erlang station link. -- **Breaking: a `Session` is a cloneable handle with one reader.** Its - methods take `&self`, so calls, subscriptions, publishing and serving on - one session run at the same time. A reader task routes each RESULT or - ERROR to its call by call id, each EVENT to the subscriptions it matches, - and each inbound CALL to a queue of 64 that `serve_one_call` and - `serve_one_call_gated` take from. A slow subscriber never delays a call's - reply. The functions in `dht`, `content`, `stream` and `direct_dial` take - `&Session` instead of `&mut Session`. -- **Breaking: `Session::subscribe` returns a `Subscription`** with its own - queue of 256 events, read with `recv_event(timeout)` and ended with - `close()`. A topic matches segment by segment on `/`, where `*` is exactly - one segment, and the realm must be equal. Closing the last subscription - for a realm and topic sends UNSUBSCRIBE. A subscription that falls more - than 256 events behind returns its queued events and then - `RecvEventError::Overflow`, and stays subscribed at the station until it - is closed. `Session::recv_event`, `unsubscribe`, `recv_frame`, - `recv_frame_timeout` and `leftover_bytes` are removed, and - `run_subscriber` runs on a `Subscription`. -- **Breaking: new error types.** `Session::call` and `call_with_ucan` - return `CallError`: `Timeout { write_started }`, - `SessionEnded { reason, write_started }`, `SendTimeout`, `Encode`, - `Write` or `MalformedReply`, where `not_sent()` tells whether the CALL - can safely be sent again. `publish`, `advertise`, `unadvertise` and - `subscribe` return `SendError`, `RecvEventError` is `Timeout`, `Overflow` - or `SessionEnded`, `serve_one_call` returns `ServeCallError`, and - `FrameStream`'s call error is renamed `StreamCallError`. -- **Writes are bounded.** A caller waits for its turn to write no longer - than its deadline, a call's timeout or else 30 seconds. A write that - takes longer than 30 seconds ends the session. The reader never waits on - a write: an inbound CALL that finds the queue full is answered with - `temporary_relay_failure` through a separate queue of 64 frames, and - serving carries on. -- **How a session ends.** A GOODBYE, a HELLO or CONNECT after the handshake, - a frame that doesn't decode, a stalled write or the end of the control - stream ends the session: its pending calls fail with `SessionEnded`, its - connection closes, direct dial no longer reuses it, and the end is logged - once through the `log` crate, as a warning when the station or connection - ended it and as info when it was closed here, with both node ids. - `Session::end_reason` and `ended` report it. Frames no route claims are - counted by type in `unrouted_frame_counts`, with a log line at most once - a minute. -- **`Pool::call` publishes no RPC facts.** A pooled call goes through the - link's session without the `rpc.sent_v1` and `rpc.completed_v1` facts - that `Session::call` publishes. -- **Breaking: `Pool::call` tries another link only when the CALL was not - sent.** It moves on to the next connected link only while a call fails - before its CALL was written, so no CALL runs twice. A call that timed out - after its write started, and an ERROR reply, are returned as they are. - `PoolCallError::AllFailed` is replaced by `PoolCallError::Call`, the - failure that stopped the call. -- **A pool link is dialed again when its session ends**, instead of when a - call or publish on it fails, so a call that times out on a link that is - still up no longer drops that link. +- `rust-version` is 1.89, the least the dependencies build with. +- `Cargo.lock` is committed and CI tests with `--locked`. ### [0.3.0] - 2026-09-05 @@ -442,30 +342,35 @@ did, but the two have moved at different paces ever since). ### [ffi-0.4.0] - Unreleased +**Breaking throughout**: rewritten on `macula-rust` 0.4's pool, the macula 12 +wire. + +#### Added + +- `FfiNodeKey`: `generate`, `load`, `load_or_create`, `save`, + `load_from_keystore` and `save_to_keystore`, in `FfiProfile::PqPure` or + `PqHybrid`. +- `FfiPool`: `connect` with pinned `FfiSeed`s and `FfiPoolOptions` (realm + trust, timeouts, bounds). It offers `call`, `providers`, `publish`, + `subscribe`, `serve`, `serve_stream`, `open_stream`, the DHT's + `find_record`, `find_records`, `find_records_by_type` and `put_record`, + `status` and `close`. +- `FfiCallHandler` and `FfiStreamHandler`, implemented by the app + (`suspend fun` in Kotlin, `async throws` in Swift). A handler's thrown + `FfiError` reaches the caller as a handler_error. +- `FfiSubscription` (`next` with a timeout, `unsubscribe`), `FfiStream` on + either side, `FfiServed`, and `own_procedure`. +- `FfiError` maps the pool's errors, a provider's error with its code, and a + foreign handler's unexpected throw. + +#### Removed + +- `FfiKeyPair`, `FfiSession`, `FfiTrust`, the UCAN functions, direct-dial and + cert-chain calls, content transfer, and the live tests that used them. + #### Changed -- Builds on `macula-rust` 0.4, with the dependency requirement moved to - `"0.4"`. The direct-dial calls on `FfiSession` therefore try every - authorized provider, and their `timeout_ms` now bounds finding the - provider as well. A `get_direct` whose transfer the timeout cuts off - reports `FfiError::Content` with the earlier failure in its reason. -- `FfiSession::serve_one_call_gated` refuses a UCAN token whose `aud` isn't - the calling node's id as lowercase hex, and both serve calls drop a CALL - that isn't signed by its caller. Mint tokens for gated procedures with - `ucan_create` using that audience. -- **Breaking: `FfiOpenedDirectStream` carries a `lease` instead of a - `session`.** The direct-dial stream and content calls on `FfiSession` run - on a session this process already has open to the provider's station - under the same identity, instead of dialing a second one that would close - it. Call `FfiSessionLease::release` once the stream is done: a session - direct dial dialed closes when no other direct-dial request still uses it. -- **Breaking: `FfiSession::subscribe` returns an `FfiSubscription`**, read - with `recv_event(timeout_ms)` and ended with `close()`. Each subscription - has its own queue of 256 events and receives only the events its topic - and realm match. `FfiSession::recv_event` and `unsubscribe` are removed. -- Methods on one `FfiSession` no longer wait for each other: - `serve_one_call`, `accept_stream`, calls and subscriptions on the same - session run at the same time. +- `rust-version` is 1.91, the least the dependencies build with. ### [ffi-0.3.1] - 2026-09-05 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..27d8431 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2929 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c6349ddff23194f8fdce2ea8849380f5a4868c1648965b70e801e104cba9b3" +dependencies = [ + "base64 0.22.1", + "jni 0.21.1", + "keyring-core", + "log", + "ndk-context", + "regex", + "serde", + "serde_json", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + +[[package]] +name = "askama" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6024d73179f43f15ccd2b881bfea6fee7f3a46ec53f33b52210dea749ebebaa4" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071ee5ebf2138e3ad180e0aacf6940c2cab5e6d8333741d9925c7bee2b153f39" +dependencies = [ + "askama_parser", + "basic-toml", + "glob", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 3.0.6", +] + +[[package]] +name = "askama_macros" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643e1c7cbb6aec1d920332fe51a7c0d8219e273dcb8602db03f5263e4d16487b" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c5ae75772275d268b03ab8bdccdd12117b6169ee23256942b34e46c9f476583" +dependencies = [ + "rustc-hash", + "serde", + "serde_derive", + "unicode-ident", + "winnow", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.21", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compat" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c97d7ff3c25d6c10d64170c12acaf5d4245e76dece3779c1d92b153a64f11df" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastbloom" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" +dependencies = [ + "foldhash", + "libm", + "portable-atomic", + "siphasher", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyring" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" +dependencies = [ + "android-native-keyring-store", + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-keyutils-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39fbed79f71dc21eb21d3d07c0e908a3c58ff9a1fdbf5cf44230fb3deb6d994b" +dependencies = [ + "keyring-core", + "linux-keyutils", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "macula-keccak" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99a9ef94be3261f1debe3b04bebcafb992f7f29fb990a87e080365a810567bb" +dependencies = [ + "zeroize", +] + +[[package]] +name = "macula-mldsa" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e846a49c6be27e4e9abf33a88ac1edcfc4e0c3308e2bdb92f12e101c05856ebb" +dependencies = [ + "getrandom 0.4.3", + "macula-keccak", + "zeroize", +] + +[[package]] +name = "macula-mlkem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1dde77fd0a48f76533836a342cd4c2cf844f63e2d534330b182a52b85b3471" +dependencies = [ + "getrandom 0.4.3", + "macula-keccak", + "zeroize", +] + +[[package]] +name = "macula-pqc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd8755ca194c2d381cb15f49f0ca2022c86129e8c6b8c9bd685d3c77b728af" +dependencies = [ + "macula-mldsa", + "macula-pqc-kx", + "rcgen", + "rustls", +] + +[[package]] +name = "macula-pqc-kx" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151b72a3953fe9bfa0046421046b13b7cd360133d8cd1caf39b468ac25f98456" +dependencies = [ + "macula-mlkem", + "rustls", +] + +[[package]] +name = "macula-rust" +version = "0.4.0" +dependencies = [ + "apple-native-keyring-store", + "aws-lc-rs", + "hex", + "keyring", + "keyring-core", + "linux-keyutils-keyring-store", + "macula-mldsa", + "macula-pqc", + "quinn", + "rcgen", + "rustix", + "rustls", + "serde_json", + "sha2", + "tempfile", + "tokio", +] + +[[package]] +name = "macula-rust-ffi" +version = "0.4.0" +dependencies = [ + "async-trait", + "hex", + "macula-rust", + "serde_json", + "tempfile", + "thiserror 2.0.21", + "tokio", + "uniffi", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "fastbloom", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "aws-lc-rs", + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "secret-service" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" +dependencies = [ + "aes", + "cbc", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hybrid-array", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" +dependencies = [ + "smawk", + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "uniffi" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76407f5f396a2c949a069eff4a9fca9ce462410eb872bffef4c2b7b89804a77a" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df3ced8f0eda3de99f6d50820e8628f443da8e4f447f477d1403ceba29dacbd" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_core" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7530ae8efeaaa488622865966763fe0294832779fff80508c36d69c6a874a6a" +dependencies = [ + "anyhow", + "async-compat", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f4bad017164375d99450d70495014810d15a84ba9da6ed14b9628b1424223ba" +dependencies = [ + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "uniffi_macros" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b855a58da153739ce6e863f6e296edc3716a093768c4d3d957f17f5e4317c60" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af0c88664a9cb9559856f5a250283c9708923b303170e2e5c7db8f4ad65e9459" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d00f5c044b4a3dca0b1226c92ddaeea626f337470e22180dd182160c87a06b0b" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55dfd9c778d6b39cb5acd541d9ff63431974035d2bad83c897b766a6ee3152ac" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.21", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index b32e262..2e5fb44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["macula-rust-ffi"] name = "macula-rust" version = "0.4.0" edition = "2021" -rust-version = "1.85" +rust-version = "1.89" authors = ["Macula "] description = "Rust SDK for the macula 12 mesh: ML-DSA-87 and LAMPS composite node keys, a post-quantum QUIC transport — mobile first, not mobile-only." license = "Apache-2.0" diff --git a/README.md b/README.md index 223ed3c..dfc866d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/macula-io/macula-rust/ci.yml?branch=master&label=CI)](https://github.com/macula-io/macula-rust/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](#license) -[![Rust](https://img.shields.io/badge/rust-1.85%2B-orange?logo=rust)](https://www.rust-lang.org) +[![Rust](https://img.shields.io/badge/rust-1.89%2B-orange?logo=rust)](https://www.rust-lang.org) [![memory safety](https://img.shields.io/badge/memory%20safety-100%25%20safe%20Rust-success.svg)](https://github.com/rust-secure-code/safety-dance/) [![GitHub Sponsors](https://img.shields.io/badge/GitHub%20Sponsors-support-ea4aaa.svg?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/rgfaber) @@ -14,211 +14,159 @@

- Rust port of the Macula SDK wire protocol — mobile first, not mobile-only + Rust SDK for the Macula mesh, with Kotlin and Swift bindings

--- -> **Status, 2026-08-30:** feature-complete for a leaf/edge client — -> the client/leaf side of the wire protocol is built and -> **live-verified against the production station fleet** -> (`station-de-frankfurt.macula.io`) — handshake (pinned or WebPki -> trust), unary RPC, PubSub, content transfer, and streaming RPC, every -> primitive in both caller and provider roles, plus direct-dial -> (DHT resolve/publish, both plain and cert-chain-authorized), periodic -> re-advertise, UCAN (mint/verify/introspect — policy-gated serving's -> live-network behavior needs a closer look, see Known limitations), a -> supervised PubSub pair, RPC telemetry auto-facts, and an -> overridable-per-platform `KeyStore` for identity persistence. Mobile -> bindings (Kotlin + Swift, via UniFFI) wrap almost the entire surface, -> generated and CI-checked on every push. See [Status](#status) for -> what's deliberately out of scope vs. genuinely separate future work, -> and [Known limitations](#known-limitations) for one real external bug -> this crate can't fix. +> **Status, 2026-09-26:** on the **macula 12** wire. That means post-quantum +> ML-DSA-87 identities (in pq_hybrid, the fleet's profile, the ML-DSA-87 + +> RSA-PSS-4096 composite), ML-KEM hybrid key exchange, and signed requests. +> Calls and streams by direct dial, serving (under an org or in a node's own +> namespace), publish/subscribe and the DHT are tested against in-process +> macula 12 stations on every `cargo test`, and live against the fleet. Not +> here yet: UCAN-gated calls and node-served content; see [Not yet +> implemented](#not-yet-implemented). Releases before 0.4.0 speak the retired +> 10.x wire and cannot reach the current fleet. ## What is this? -A ground-up Rust implementation of the client half of Macula's wire -protocol — the same protocol [`macula-io/macula`](https://github.com/macula-io/macula) -(the Erlang/OTP SDK) speaks, extracted directly from that source and -tracked in [`plans/PLAN_WIRE_PROTOCOL.md`](plans/PLAN_WIRE_PROTOCOL.md). -Macula is a federated mesh for sovereign, end-to-end-encrypted -application networks; a **station** is the relay/DHT node, and this crate -is what a **leaf** — a phone, a desktop app, a CLI, anything that isn't -itself a station — uses to join it. +A native Rust implementation of a Macula node: its identity key, a pool of +links to the stations it pins by node_id, calls and streams that reach a +provider at its own station, serving procedures, publish/subscribe, and the +DHT. It speaks the same wire as [macula](https://github.com/macula-io/macula) +(the Erlang/OTP reference) and [macula-go](https://github.com/macula-io/macula-go), +over QUIC ([quinn](https://github.com/quinn-rs/quinn)) with the post-quantum +TLS of [macula-pqc](https://crates.io/crates/macula-pqc), ML-DSA-87 from +[macula-mldsa](https://crates.io/crates/macula-mldsa), and RSA-PSS-4096 from +aws-lc-rs. -Mobile is the flagship consumer driving the work (hence the UniFFI -crate), not a ceiling on it: the core crate has zero UniFFI dependency -and zero FFI-shaped types, so it's exactly as usable from plain Rust, a -CLI, or WASM as any other Rust SDK. - -## Features - -| Primitive | Caller | Provider | Notes | -|---|---|---|---| -| Handshake (CONNECT/HELLO) | ✅ | — | Ed25519 identity, S/Kademlia puzzle-hardened | -| One session, many uses | ✅ | ✅ | `Session` is a cloneable handle with one reader: calls, subscriptions and serving on it run at the same time, and a slow consumer never stalls a call's reply | -| Unary RPC (CALL/RESULT/ERROR) | ✅ | ✅ | `Session::serve_one_call`, BOLT#4 error mapping live-verified; a call that times out says whether its frame was sent | -| PubSub (PUBLISH/SUBSCRIBE/EVENT) | ✅ | ✅ | `Session::subscribe` returns a `Subscription` with its own queue of 256 events; a subscriber gets its own publish, verified live | -| Content transfer (single-block + chunked) | ✅ | ✅ | Content-addressed, BLAKE3/SHA-256 | -| Streaming RPC (STREAM_OPEN/DATA/END/REPLY) | ✅ | ✅ | Both roles live-verified against the real fleet; `ClientStream` mode's reply path is SDK-correct but currently blocked by a `macula-station` bug — see [Known limitations](#known-limitations) | -| RPC advertise/unadvertise | ✅ | — | | -| Direct-dial (DHT resolve/publish) | ✅ | ✅ | `direct_dial::{resolve,call,advertise_direct}` — reaches a service without depending on advertise-gossip having propagated a route; plain + cert-chain-authorized (`*_with_cert_chain`) | -| Direct-dial streaming/content | ✅ | ✅ | `direct_dial::{open_stream_direct,put_direct,get_direct}` — runs on a session already open to the same station under the same identity instead of dialing a second one, which the station would answer by closing the first; `get_direct` is correct but currently unreachable, see [Known limitations](#known-limitations) | -| Periodic re-advertise | — | ✅ | `Session::keep_advertised` / `direct_dial::keep_advertised_direct` — a ctx-cancellable loop, since a station's registration doesn't survive the connection that sent it being replaced | -| UCAN (mint/verify/introspect) | ✅ | ✅ | `ucan::{create,verify,decode,get_*}` are pure functions; `Session::call_with_ucan`/`serve_one_call_gated` live-verified end-to-end; a gated provider accepts a token only when its `aud` is the calling node's id as lowercase hex, and drops a CALL not signed by its caller (see [`examples/ucan.rs`](examples/ucan.rs) and [Known limitations](#known-limitations) for the resolved investigation) | -| Cert-chain (org/realm authorization) | ✅ | ✅ | `cert_chain::verify_advertisement_cert_chain` + `direct_dial::*_with_cert_chain` — opt-in, the plain direct-dial path is unaffected | -| Supervised PubSub pair | ✅ | ✅ | `Session::run_publisher`/`run_subscriber` — addressable/cancellable wrappers over bare publish/subscribe, auto-publishing `pubsub.publish_*_v1` facts | -| RPC telemetry auto-facts | ✅ | ✅ | `rpc.sent_v1`/`rpc.completed_v1` (caller), `rpc.received_v1`/`rpc.replied_v1` (provider) — always-on, fire-and-forget, fired automatically by `call`/`serve_one_call_gated` | -| Overridable `KeyStore` | ✅ | — | `keystore::KeyStore` trait + `KeyringStore`/`LinuxKeyutilsStore` — `KeyPair::save_to_keystore`/`load_from_keystore`; the raw-file `KeyPair::save` stays as a testing/parity convenience | -| Mobile bindings (Kotlin, Swift) | ✅ | ✅ | Via [UniFFI](#mobile-bindings-uniffi) — provider role serves via `FfiCallHandler`, a foreign-implemented async trait (`suspend fun`/`async throws`), not a closure. Covers direct-dial, UCAN, cert-chain, content/stream direct-dial reuse, and `KeyStore`; deliberately NOT `keep_advertised`/`run_subscriber` (see the FFI crate's own module doc for why) | -| Pubkey-pinned trust | ✅ | — | `Trust::Pinned` / `FfiTrust.Pinned` — the only mode that works at all for a station without a CA-issued cert | -| Post-quantum key exchange | ✅ | — | Every dial, in every trust mode, via [`macula-pqc`](https://crates.io/crates/macula-pqc): `SecP384r1MLKEM1024`, then `SecP256r1MLKEM768`, nothing classical. A station on macula 11.5.0 or earlier offers only classical groups and cannot be reached. Key exchange only: certificates are still classically signed | - -`unsafe_code = "forbid"` at the crate level — the only unsafe in this -workspace lives inside its dependencies (`quinn`, `ring`, `aws-lc-rs`), -not here. +[`macula-rust-ffi`](#mobile-bindings-kotlin-and-swift) wraps it for Kotlin and +Swift. The core crate has no FFI dependency and no FFI-shaped types. ## Quick start -Also lives as a runnable example — `cargo run --example quickstart`. -Advertises and calls its own trivial echo procedure (two identities, a -provider and a caller, since a station kicks a connection the instant a -second one arrives under the same identity) rather than depending on any -particular procedure already being advertised on the fleet: +```toml +[dependencies] +macula-rust = "0.4" +tokio = { version = "1", features = ["full"] } +``` + +A node needs a station to link to, **pinned by its node_id**, and the key of +each realm it trusts, which the realm publishes. Its own key is created on +first use and kept in a file its owner alone can read. ```rust -use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use macula_rust::{ - cbor::Value, - connection::{self, BoxFuture, CallHandler}, - frame::AdvertiseSpec, - identity::KeyPair, - transport::Trust, -}; - -#[tokio::main] -async fn main() -> Result<(), Box> { - // Puzzle-hardened identities — required. An unhardened identity fails - // the handshake silently (QUIC/TLS looks healthy, HELLO never accepts). - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = connection::connect( - "station-de-frankfurt.macula.io", - 4433, - Trust::WebPki, - &provider_identity, - ) - .await?; - let caller_session = connection::connect( - "station-de-frankfurt.macula.io", - 4433, - Trust::WebPki, - &caller_identity, - ) +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use macula_rust::cbor::Value; +use macula_rust::node_key::NodeKey; +use macula_rust::pool::{Call, Opts, Pool, Seed}; +use macula_rust::profile::Profile; +use macula_rust::station_link::Publication; + +let key = NodeKey::load_or_create(Path::new("node.key"), Profile::PqHybrid)?; +let mut opts = Opts::new(Arc::new(key)); +opts.realm_trust = HashMap::from([(realm, realm_key)]); +let pool = Pool::connect( + vec![Seed { host: "station-fi-helsinki.macula.io".into(), port: 4433, node_id: station_id }], + opts, +) +.await?; + +// A call reaches a provider by direct dial: its advertisement from the DHT, +// trusted only when the realm key authorizes it, and its station dialed. +let answer = pool + .call(Call { realm, procedure: "mcl-echo/echo".into(), payload: Value::text("hello"), ..Call::default() }) .await?; - let realm = [0u8; 32]; - // Unique per run — reusing a fixed procedure name across rapid - // repeated runs can hit stale DHT routing state from the prior run's - // now-dead advertiser. - let procedure = format!( - "macula_rust.quickstart_echo.{}", - SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() - ); - - let advertise_spec = AdvertiseSpec::new(realm, procedure.clone(), provider_identity.node_id()); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await?; - tokio::time::sleep(Duration::from_millis(500)).await; // ADVERTISE is fire-and-forget; give it a moment to land - - let target_procedure = procedure.clone(); - let lookup = move |_realm: &[u8; 32], proc: &str| -> Option { - if proc != target_procedure { - return None; - } - let handler: CallHandler = std::sync::Arc::new(|payload: Value| { - Box::pin(async move { Ok(payload) }) as BoxFuture<'static, Result> - }); - Some(handler) - }; - - let serve_task = tokio::spawn(async move { - let result = provider_session - .serve_one_call(lookup, &provider_identity, Duration::from_secs(10)) - .await; - // Close explicitly instead of letting provider_session drop when - // this task ends — see Session's own doc for why: dropping the - // last handle closes the connection at once, which gives quinn's - // send-scheduling no guarantee the RESULT just sent actually - // reached the peer first. - provider_session - .close( - "normal", - Some("quickstart provider done"), - &provider_identity, - ) - .await; - result - }); - - let now_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as i128; - let response = caller_session - .call( - &procedure, - realm, - Value::Text("hello".into()), - now_ms + 5_000, // deadline_ms - &caller_identity, - Duration::from_secs(5), - ) - .await?; - - serve_task.await??; - caller_session - .close("normal", Some("quickstart caller done"), &caller_identity) - .await; - - println!("{response:?}"); - Ok(()) -} +// Publish and subscribe; topics name a kind of fact, ids go in the payload. +let mut sub = pool.subscribe(&realm, "acme/demo/greeting_sent_v1").await?; +pool.publish(Publication { + realm, + topic: "acme/demo/greeting_sent_v1".into(), + payload: Value::Map(vec![(Value::text("text"), Value::text("hi"))]), + ttl_ms: None, +}) +.await?; +let event = sub.recv().await; + +pool.close().await; ``` -## Mobile bindings (UniFFI) - -`macula-rust-ffi` is a separate crate — not code bolted onto the -core one — wrapping every application primitive (`FfiSession::connect`/ -`call`/`serve_one_call`/`publish`/`subscribe`/`content_put`/ -`content_get`/`stream_open`/`advertise`/`accept_stream`) for Kotlin and -Swift, in the modern proc-macro UniFFI style (`#[uniffi::export]`, -native `async`/`await` and Kotlin coroutines, no `.udl` file). CI -rebuilds the `cdylib` and regenerates both language bindings on every -push as a codegen smoke test. +Serving a procedure in the node's own namespace needs no org and no realm key: -Serving an RPC from Kotlin or Swift means implementing `FfiCallHandler` -— a **foreign trait** (`#[uniffi::export(foreign)]`), not a callback -closure (UniFFI foreign traits can't carry a plain closure, so -`handle` receives the full inbound call and does its own procedure -routing if a session serves more than one): - -```kotlin -class Doubler : FfiCallHandler { - override suspend fun handle(procedure: String, realm: ByteArray, payload: FfiValue): FfiValue { - val n = (payload as FfiValue.Int).v1 - return FfiValue.Int(n * 2) - } -} +```rust +use macula_rust::pool::Offer; +use macula_rust::record::own_procedure; +use macula_rust::station_link::handler; -session.advertise("math.double", realm, identity) -session.serveOneCall(Doubler(), timeoutMs = 30_000u, identity) +let ring = own_procedure(&pool.node_id(), "ring"); // ~/ring +let served = pool + .serve(Offer::unary(realm, &ring, handler(|request| async move { Ok(request.payload) }))) + .await?; ``` -(`FfiValue` currently covers `Null`/`Int`/`Bytes`/`Text`/`Float` — see -this crate's own module doc for why `List`/`Map` aren't there yet; a -handler needing a structured payload should encode it as `Bytes` -today.) +Runnable versions are in [`examples/`](examples): `quickstart`, `serve` and +`publish_subscribe`, each reading the environment described at the top of +[`examples/common/mod.rs`](examples/common/mod.rs). + +### Coming from 0.3 and earlier + +Everything moved to the macula 12 wire, and the API with it. There is no +compatibility layer. + +- **New identities.** A macula 12 node_id derives from an ML-DSA-87 key (or + the LAMPS composite in `pq_hybrid`), so no Ed25519 identity carries over. + `NodeKey::load_or_create` makes a new key file. **Re-join your realms and + re-trust your agents**: anything that named your old node_id must be redone + with the new one. +- `identity::KeyPair` is now `node_key::NodeKey`; `connection::Session` is + `pool::Pool` (or `station_link::Link` for one station), whose seeds carry + the station's node_id and whose `realm_trust` pins realm keys; + `direct_dial::call` is simply `Pool::call`; `resolve` is `Pool::providers`; + `serve_one_call` is `Pool::serve` with a handler; `Trust::WebPki` is gone: + every station is pinned by its node_id. +- `ucan`, `cert_chain` and the content-transfer modules are gone until + macula 12's own arrive (see [Not yet implemented](#not-yet-implemented)). +- Serving an org procedure needs the realm's org directory and the org's + delegation to your node in the DHT: a realm admits orgs through a human. + +## What's implemented + +| Primitive | Caller | Provider | Notes | +|---|---|---|---| +| Node keys (`node_key::NodeKey`) | ✅ | ✅ | `pq_hybrid` (the fleet's) or `pq_pure`; key files readable by the owner only, or the platform's secure store (`keystore`); pq_hybrid checked against the LAMPS draft's own vector and cross-verified with macula 12.8.0 | +| Pool of station links (`pool::Pool`) | ✅ | ✅ | Seeds pinned by node_id; realm keys pinned; links redialed with subscriptions and served procedures replayed | +| One station link (`station_link::Link`) | ✅ | ✅ | The v4 handshake, status statements both ways, neighbour signatures in pq_hybrid, a liveness probe | +| Calls by direct dial (`call`, `providers`) | ✅ | ✅ | Candidates tried freshest first; errors arrive as `LinkError::Provider` / `LinkError::Relay` | +| A node's own namespace (`record::own_procedure`) | ✅ | ✅ | `~/`: served and called with no org and no realm key | +| Streams (`open_stream`, `Offer::stream`) | ✅ | ✅ | Server, client and bidi; a QUIC stream per session, released on every path | +| Publish/subscribe | ✅ | ✅ | Signed publications, delivered once across links | +| DHT (`find_record`, `find_records`, `find_records_by_type`, `put_record`) | ✅ | — | Records verified before they are handed on | +| Mobile bindings (Kotlin, Swift) | ✅ | ✅ | `macula-rust-ffi`, below | + +The link and the pool are ported from macula-go v0.12.0's `stationlink` and +`pool`, and every wire format is checked against macula-go's and macula's own +vectors (`tests/vectors/`). `unsafe_code = "forbid"` holds across the +workspace; the unsafe code is inside dependencies (quinn, aws-lc-rs). + +## Payloads + +A payload is what macula's wire CBOR carries: `Value::Null`, `Int`, `Float`, +`Text`, `Bytes`, `List` and `Map`. **There is no boolean**: write 1 or 0. A +decoded payload obeys macula 12's decoding rule (depth 64, 131,072 elements, +integers within ±2^63, text or integer map keys, no duplicates). + +## Mobile bindings (Kotlin and Swift) + +`macula-rust-ffi` wraps the pool with [UniFFI](https://mozilla.github.io/uniffi-rs/) +proc macros: `FfiNodeKey`, `FfiPool`, `FfiSubscription`, `FfiStream`, and two +handlers the app implements, `FfiCallHandler` and `FfiStreamHandler` +(`suspend fun` in Kotlin, `async throws` in Swift). Every 32-byte id crosses as +bytes and is checked. ```bash cargo build -p macula-rust-ffi --release @@ -227,216 +175,74 @@ cargo run -p macula-rust-ffi --release --bin uniffi-bindgen -- generate \ --language kotlin --out-dir bindings-kotlin ``` -### Connecting and a basic call - -Signatures cross-checked against real generated bindings (`uniffi-bindgen generate`, both languages), not guessed — `call` takes no separate deadline, only a timeout. Calls `math.double`, the procedure the [`Doubler`](#mobile-bindings-uniffi) example above this one advertises and serves — this SDK's own, not a fleet-wide service, so it only resolves while that example (or an equivalent provider) is actually running: - ```kotlin -val identity = FfiKeyPair.generate() -val session = FfiSession.connect("station-de-frankfurt.macula.io", 4433.toUShort(), FfiTrust.WebPki, identity) -val response = session.call("math.double", realm, FfiValue.Int(21), 5_000uL, identity) -``` +class Echo : FfiCallHandler { + override suspend fun handle(request: FfiRequest): FfiValue = request.payload +} -```swift -let identity = FfiKeyPair.generate() -let session = try await FfiSession.connect(host: "station-de-frankfurt.macula.io", port: 4433, trust: .webPki, identity: identity) -let response = try await session.call(procedure: "math.double", realm: realm, payload: .int(21), timeoutMs: 5_000, identity: identity) +val key = try { + FfiNodeKey.loadFromKeystore("io.macula.myapp", "node-identity", FfiProfile.PQ_HYBRID) +} catch (e: FfiException.KeystoreNotFound) { + FfiNodeKey.generate(FfiProfile.PQ_HYBRID).also { it.saveToKeystore("io.macula.myapp", "node-identity") } +} +val pool = FfiPool.connect(key, listOf(FfiSeed(host, 4433.toUShort(), stationId)), + FfiPoolOptions(realmTrust = listOf(FfiRealmKey(realm, realmKey)))) +pool.serve(realm, ownProcedure(pool.nodeId(), "ring"), Echo()) +val answer = pool.call(realm, "mcl-echo/echo", FfiValue.Text("hello"), null, 5_000uL) ``` -### Persisting identity via platform secure storage +On Android the platform keystore needs one call at app start, +`Keyring.initializeNdkContext(applicationContext)`; see the `keystore` +module's documentation. iOS needs nothing extra. -Real, working usage — this is `macula-apps/macula-cam2me`'s actual -Android identity persistence, not a contrived snippet. Android needs one -extra one-time call at app startup (Keystore has no NDK surface, so the -`android-native-keyring-store` crate ships its own JNI init export); iOS -needs nothing extra, since `apple-native-keyring-store` covers both -macOS and iOS as one backend. `saveToKeystore`/`loadFromKeystore` are -plain blocking calls, not `suspend`/`async` — note the `FfiError` -variant name is `KeystoreNotFound` (capitalized, mirroring the Rust -error type directly) in both languages, unlike `FfiTrust`/`FfiValue`'s -ordinary lower-camelCase Swift cases (`.webPki`, `.text`) — a real, -confirmed UniFFI codegen quirk, not a typo. - -```kotlin -// Once, in Application.onCreate or MainActivity.onCreate: -Keyring.initializeNdkContext(applicationContext) +CI generates both bindings on every push to master and every pull request; +the apps that use them compile them. The FFI crate needs Rust 1.91, the core +crate 1.89. -// Then anywhere: -val identity = try { - FfiKeyPair.loadFromKeystore("io.macula.myapp", "node-identity") -} catch (e: FfiException.KeystoreNotFound) { - FfiKeyPair.generate().also { it.saveToKeystore("io.macula.myapp", "node-identity") } -} -``` +## Not yet implemented -```swift -// No extra init needed on iOS. -let identity: FfiKeyPair -do { - identity = try FfiKeyPair.loadFromKeystore(service: "io.macula.myapp", account: "node-identity") -} catch FfiError.KeystoreNotFound { - identity = FfiKeyPair.generate() - try identity.saveToKeystore(service: "io.macula.myapp", account: "node-identity") -} -``` +- **UCAN-gated calls and serving.** macula 12 uses post-quantum UCANs; calls + carry no token yet, and a gated procedure cannot be served. +- **Node-served content** (macula 12's D27): planned for 0.5.0. +- **Station discovery beyond the seeds.** macula's discovery call is not + served by the fleet today (macula-io/macula#31); give the pool its seeds. ## Testing ```bash -cargo test --workspace --all-features +./scripts/build-teststation.sh # macula-go's in-process stations, to target/teststation +cargo test --workspace ``` -100+ tests across the workspace, plus a separate live-verification suite -(`tests/live_station.rs`) that dials the real production fleet — -`#[ignore]`d by default since it depends on infrastructure this crate -doesn't control: +The integration tests (`tests/station_link.rs`, `tests/pool.rs`, +`macula-rust-ffi/tests/pool_ffi.rs`) run against `tests/teststation`, a Go +helper around macula-go's `teststation`. It starts in-process macula 12 +stations, realms and orgs as each test asks, and reports what a station sees +(who is connected, what is advertised or subscribed, how many streams it +relays). A test fails, not skips, when the helper is missing. No network is +needed. Go ≥ 1.27 builds the helper. + +`tests/live.rs` runs against one real station and is ignored unless asked: ```bash -cargo test --test live_station -- --ignored --nocapture +MACULA_RUST_LIVE_SEED=station-fi-helsinki.macula.io:4433 \ +MACULA_RUST_LIVE_STATION_ID=<64 hex> MACULA_RUST_LIVE_REALM=<64 hex> \ +MACULA_RUST_LIVE_REALM_KEY= cargo test --test live -- --ignored ``` -## Status - -**Live-verified, 2026-08-28 — full parity, both directions:** handshake, -CALL/RESULT/ERROR as both caller (`Session::call`) and provider -(`Session::serve_one_call`, BOLT#4 error mapping — `unknown_next_peer` -on a lookup miss, `temporary_relay_failure` on a handler panic (caught -via `tokio::spawn`, one task per call, the same shape -`macula_station_link.erl`'s one-process-per-call already uses), -`unknown_error` with detail on a handler-returned error, all ported -field-for-field from that module's `handle_inbound_call/2`), PUBLISH/ -SUBSCRIBE/EVENT (a subscriber does receive its own publish), content -transfer, and streaming RPC in both the caller and provider roles — all -against `station-de-frankfurt.macula.io`, the real fleet, not a local -mock. Two independent connections to the same station (one advertising -and serving, the other calling in) is the pattern behind every -provider-role test — see `tests/live_station.rs`'s -`unary_call_provider_round_trip_against_the_real_fleet` for the unary -case. Three real protocol bugs were caught by differential-vector tests -before ever touching production. - -Unary-RPC provider dispatch was the one gap left after the streaming -and content-transfer provider roles landed — a service built on this -crate could call RPCs and serve streams, but couldn't serve a -request/response procedure at all. It's now built here and in -[`macula-go`](https://github.com/macula-io/macula-go) in the -same pass, so both SDKs serve RPCs, not just call them, and wrapped in -the FFI layer the same day: [`FfiCallHandler`](#mobile-bindings-uniffi) -is a **foreign trait** (`#[uniffi::export(foreign)]`), not a callback -closure — UniFFI doesn't support passing a bare closure across the -boundary, so `handle` receives the full inbound call and a Kotlin/Swift -implementation does its own procedure routing if a session serves more -than one. Verified past "it compiles": rebuilt the release `cdylib`, -regenerated both Kotlin and Swift, and inspected the actual generated -code — `FfiCallHandler.handle` renders as `suspend fun ... : FfiValue` -in Kotlin and `func handle(...) async throws -> FfiValue` in Swift, -`FfiSession.serveOneCall`/`serveOneCall` takes it as a parameter in -both, not just as an exit-code smoke test. - -Pubkey-pinned trust reached the FFI layer the same day too: `connect` -now takes an `FfiTrust` (`Pinned { node_id }` or `WebPki`) instead of -hardcoding WebPki. Not a nice-to-have — WebPki has no chain to validate -against a self-hosted station outside the public demo fleet, so a real -deployment off `station-de-frankfurt.macula.io` needs pinning to -connect at all. `Trust::Insecure` stays deliberately unexposed at the -FFI boundary (dev/diagnostic only in the core crate; a shipped mobile -app should never be able to select "skip TLS verification"). - -**2026-08-30: direct-dial, UCAN, cert-chain, periodic re-advertise, a -supervised PubSub pair, RPC telemetry facts, and an overridable -`KeyStore` all landed, live-verified, and FFI-wrapped the same day.** -Direct-dial exists because ordinary advertise/gossip routing depends on -a route having already propagated between the caller's and the -service's station — this fleet's gossip is best-effort and often hasn't, -so direct-dial resolves a signed DHT record naming the serving station -and dials it in one hop instead. `KeyStore` closes a real gap this -crate's own `KeyPair::save` doc comment had flagged since it was -written: raw-file persistence is fine for tests, but a real mobile app -needs Keychain/Keystore-backed storage — `KeyringStore` covers macOS, -iOS, Linux (D-Bus secret service) and Windows via one `keyring`-crate -backend (confirmed via its own `Cargo.toml`: `apple-native-keyring-store` -covers macOS *and* iOS with a single backend, no per-platform bridge -needed), `LinuxKeyutilsStore` is a second backend for sandboxes with no -secret-service daemon running. `macula-apps/macula-cam2me`'s Android app -migrated to it the same day (`NodeKeyPair.kt`), the first real consumer. - -**This crate is feature-complete for its stated purpose — a leaf -client dialing a known macula-station — in both the core crate and the -FFI layer.** What's genuinely still outstanding is a different kind of -thing entirely, not an SDK gap: -- DHT/HyParView/Plumtree gossip primitives — deliberately **not** - leaf-client scope; they're how *stations* gossip membership and - broadcast to each other (§6.5-§6.7 say so explicitly). A leaf never - needs them, so this was never a completeness gap to begin with. -- The actual Android demo app — real Kotlin/Android work outside this - crate, needing a device/emulator and toolchain this repo's own CI - doesn't have. The SDK surface it needs (`advertise`/`acceptStream`/ - `FfiStream`/`serveOneCall`, both pull and push streaming modes) is - already complete and live-verified; nothing here is blocking it. -- Additional language ports (C#, Python) — a separate initiative, not - a gap in this crate. - -See [`plans/PLAN_WIRE_PROTOCOL.md`](plans/PLAN_WIRE_PROTOCOL.md) for the -full wire-format spec this crate is built against, section by section, -traced directly to the Erlang SDK's source. - -## Known limitations - -- **`direct_dial::get_direct` can only resolve a `content_announcement` - that something has actually published** — and nothing in this - ecosystem currently does, since only a station/relay can legitimately - publish one (a `content_announcement`'s endpoint is dialed with no - relay indirection, unlike a `procedure_advertisement`, so a leaf SDK - identity can't pass its own trust check). Correct but currently - unreachable, not a bug. -- **RESOLVED**: an earlier draft of this section reported - `call_direct_with_cert_chain` timing out waiting for a reply after a - successful resolve+dial, narrowed but not root-caused across several - investigation rounds. Root-caused: the same premature-`Session`-drop - race as the `serve_one_call_gated` finding below — the FFI test's - `serve_task` dropped the provider `Session` the instant - `serve_until_procedure` returned, closing the QUIC connection before - the reply frame reached the peer. Fixed by keeping the session alive - 300ms after the last reply, matching the identical fix already applied - there. Confirmed with 5 consecutive clean passes (was failing reliably - before). No SDK defect — the cert-chain mechanism itself was never - broken. See `macula-rust-ffi/tests/live_cert_chain_direct_dial.rs`'s - own comments for the ruled-out theories from the earlier rounds. -- The demo fleet's `station_endpoint` DHT records carry a short TTL and - are not always freshly republished, so a station's record can be stale - for a while. Direct dial tries every advertised provider in turn and - keeps re-querying within the call's `timeout`; only when no provider's - station has a usable record before it runs out does the call return - `StationEndpointNotFound`. This is fleet infrastructure state, not a - code defect. -- **RESOLVED**: an earlier draft of this section reported - `serve_one_call_gated`/`call_with_ucan` failing 100% of live attempts - while `serve_one_call` succeeded reliably in the same window, and left - it as an open, unconfirmed question. Root-caused: it was a test-harness - bug, not a real difference between gated and plain serving. The failing - harness spawned the provider's `Session` into a task that dropped it - the instant `serve_one_call`/`serve_one_call_gated` returned; dropping - the last `Session` handle closes the underlying QUIC connection, which - can happen before the just-sent reply frame is flushed to the peer — the exact - same class of race already documented on [`Session::close`], just - never hit by drop instead of an explicit close before now. Confirmed - by direct A/B: 8/8 plain AND 8/8 gated calls succeeded once the - provider session was kept alive briefly after serving, interleaved on - the same station in the same window; the pre-existing - `unary_call_provider_round_trip_against_the_real_fleet` test also - passed 3/3 at the same moment, ruling out the fleet-degradation theory - entirely for this specific finding. **Practical takeaway for any - caller**: don't let a `Session` drop immediately after `serve_one_call`/ - `publish`/any send-then-return call — keep it alive briefly (or call - [`Session::close`] explicitly) so in-flight writes have time to reach - the wire. See `examples/ucan.rs` for a real, live-verified gated-serving - example built once this was root-caused. - -## Related projects - -| Project | Description | +With a key generated for the run and never saved, it reads the DHT, calls +`mcl-echo/echo` by direct dial and hears its own publication. +`scripts/cross-verify-macula.sh` renews the pq_hybrid signatures that crossed +both ways with macula (`tests/vectors/identity/macula_12_cross`). + +## Sibling SDKs + +| Repo | Approach | |---|---| -| [macula](https://github.com/macula-io/macula) | The reference SDK (Erlang/OTP) — the protocol this crate ports | +| [macula](https://github.com/macula-io/macula) | The reference SDK (Erlang/OTP) | +| [macula-go](https://github.com/macula-io/macula-go) | Go port; this crate's link and pool follow it | +| [macula-ts](https://github.com/macula-io/macula-ts) | FFI binding over macula-go, for Node.js | +| [macula-php](https://github.com/macula-io/macula-php) | FFI binding over macula-go, for PHP | | [macula-station](https://github.com/macula-io/macula-station) | The station: DHT, SWIM, routing, peering | | [macula-realm](https://github.com/macula-io/macula-realm) | Managed-realm identity + certificate authority | diff --git a/examples/common/mod.rs b/examples/common/mod.rs new file mode 100644 index 0000000..b30ad95 --- /dev/null +++ b/examples/common/mod.rs @@ -0,0 +1,89 @@ +//! What every example joins the mesh with, from the environment: +//! +//! - `MACULA_SEED`: the station, host:port (`[v6]:port` for IPv6) +//! - `MACULA_STATION_ID`: its node_id, 64 hex: the station must prove it +//! - `MACULA_REALM`: the realm id, 64 hex +//! - `MACULA_REALM_KEY`: the realm's key as carried, hex (the realm publishes it) +//! - `MACULA_KEY`: this node's key file, created on first use (`node.key`) +//! - `MACULA_PROFILE`: `pq_hybrid` (the fleet's, the default) or `pq_pure` + +#![allow(dead_code)] + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use macula_rust::node_key::NodeKey; +use macula_rust::pool::{Opts, Pool, Seed}; +use macula_rust::profile::Profile; + +pub fn env(name: &str) -> String { + match std::env::var(name) { + Ok(v) if !v.is_empty() => v, + _ => { + eprintln!("set {name} (see the top of examples/common/mod.rs)"); + std::process::exit(2); + } + } +} + +pub fn hex32(name: &str) -> [u8; 32] { + let bytes = hex_decode(&env(name)); + bytes.try_into().unwrap_or_else(|_| { + eprintln!("{name} must be 64 hex characters"); + std::process::exit(2); + }) +} + +pub fn realm() -> [u8; 32] { + hex32("MACULA_REALM") +} + +/// A pool on the seed, as the key in `key_file` (or `MACULA_KEY`, or +/// `node.key`), made on first use, trusting the realm. +pub async fn connect(key_file: Option<&str>) -> Pool { + let seed = env("MACULA_SEED"); + let Some((host, port)) = seed.rsplit_once(':') else { + eprintln!("MACULA_SEED must be host:port"); + std::process::exit(2); + }; + let profile = std::env::var("MACULA_PROFILE") + .ok() + .and_then(|p| Profile::parse(&p).ok()) + .unwrap_or(Profile::PqHybrid); + let key_file = key_file + .map(str::to_string) + .or_else(|| std::env::var("MACULA_KEY").ok()) + .unwrap_or_else(|| "node.key".into()); + let key = NodeKey::load_or_create(Path::new(&key_file), profile).expect("the node's key"); + let mut opts = Opts::new(Arc::new(key)); + opts.realm_trust = HashMap::from([(realm(), hex_decode(&env("MACULA_REALM_KEY")))]); + Pool::connect( + vec![Seed { + host: host + .trim_start_matches('[') + .trim_end_matches(']') + .to_string(), + port: port.parse().expect("MACULA_SEED's port"), + node_id: hex32("MACULA_STATION_ID"), + }], + opts, + ) + .await + .expect("a link to the seed") +} + +pub fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn hex_decode(text: &str) -> Vec { + (0..text.len()) + .step_by(2) + .map(|i| u8::from_str_radix(text.get(i..i + 2).unwrap_or("zz"), 16)) + .collect::>() + .unwrap_or_else(|_| { + eprintln!("not hex: {text}"); + std::process::exit(2); + }) +} diff --git a/examples/publish_subscribe.rs b/examples/publish_subscribe.rs new file mode 100644 index 0000000..b7f7d9b --- /dev/null +++ b/examples/publish_subscribe.rs @@ -0,0 +1,42 @@ +//! Subscribes to a topic and publishes to it. A topic names a kind of fact, +//! with a business verb, and ids go in the payload. There is no boolean on +//! the wire: write 1 or 0. +//! +//! Run: `cargo run --example publish_subscribe`, with the environment +//! examples/common/mod.rs reads. + +mod common; + +use std::time::Duration; + +use macula_rust::cbor::Value; +use macula_rust::station_link::Publication; + +const TOPIC: &str = "acme/demo/greeting_sent_v1"; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let pool = common::connect(None).await; + let mut sub = pool.subscribe(&common::realm(), TOPIC).await?; + tokio::time::sleep(Duration::from_millis(300)).await; + pool.publish(Publication { + realm: common::realm(), + topic: TOPIC.into(), + payload: Value::Map(vec![ + (Value::text("text"), Value::text("hi")), + (Value::text("urgent"), Value::Int(0)), + ]), + ttl_ms: None, + }) + .await?; + while let Ok(Some(event)) = tokio::time::timeout(Duration::from_secs(2), sub.recv()).await { + println!( + "{} published {:?}", + common::hex(&event.publisher), + event.payload + ); + } + sub.unsubscribe().await?; + pool.close().await; + Ok(()) +} diff --git a/examples/quickstart.rs b/examples/quickstart.rs new file mode 100644 index 0000000..aba410d --- /dev/null +++ b/examples/quickstart.rs @@ -0,0 +1,35 @@ +//! Connects to a macula 12 station and calls mcl-echo/echo, which runs on +//! another station: the pool finds its trusted advertisement in the DHT and +//! dials the station it serves from. +//! +//! Run: `cargo run --example quickstart`, with the environment +//! examples/common/mod.rs reads. + +mod common; + +use macula_rust::cbor::Value; +use macula_rust::pool::Call; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let pool = common::connect(None).await; + println!("node {}", common::hex(&pool.node_id())); + for provider in pool.providers(&common::realm(), "mcl-echo/echo").await? { + println!( + "provider {} at station {}", + common::hex(&provider.node), + common::hex(&provider.station) + ); + } + let answered = pool + .call(Call { + realm: common::realm(), + procedure: "mcl-echo/echo".into(), + payload: Value::text("hello"), + ..Call::default() + }) + .await?; + println!("mcl-echo/echo answered {answered:?}"); + pool.close().await; + Ok(()) +} diff --git a/examples/serve.rs b/examples/serve.rs new file mode 100644 index 0000000..cf14600 --- /dev/null +++ b/examples/serve.rs @@ -0,0 +1,48 @@ +//! Serves a procedure in this node's own namespace, `~/ring`, which +//! needs no org and no realm key: the node's signature authorizes it. A +//! second node, with a key of its own, calls it by direct dial. +//! +//! Run: `cargo run --example serve`, with the environment +//! examples/common/mod.rs reads. The caller's key is `caller.key`. + +mod common; + +use macula_rust::cbor::Value; +use macula_rust::pool::{Call, Offer}; +use macula_rust::record; +use macula_rust::station_link::handler; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let provider = common::connect(None).await; + let ring = record::own_procedure(&provider.node_id(), "ring"); + let served = provider + .serve(Offer::unary( + common::realm(), + &ring, + handler(|request| async move { + Ok(Value::Map(vec![( + Value::text("answered"), + Value::Bytes(request.caller.to_vec()), + )])) + }), + )) + .await?; + println!("serving {ring}"); + + let caller = common::connect(Some("caller.key")).await; + let answered = caller + .call(Call { + realm: common::realm(), + procedure: ring, + payload: Value::Null, + ..Call::default() + }) + .await?; + println!("{answered:?}"); + + served.stop().await?; + caller.close().await; + provider.close().await; + Ok(()) +} diff --git a/macula-rust-ffi/Cargo.toml b/macula-rust-ffi/Cargo.toml index 9f6eff8..1e35d5d 100644 --- a/macula-rust-ffi/Cargo.toml +++ b/macula-rust-ffi/Cargo.toml @@ -2,7 +2,7 @@ name = "macula-rust-ffi" version = "0.4.0" edition = "2021" -rust-version = "1.85" +rust-version = "1.91" authors = ["Macula "] description = "UniFFI mobile (Kotlin/Swift) bindings for macula-rust. Wraps the core crate; adds nothing to it." license = "Apache-2.0" diff --git a/macula-rust-ffi/src/node_key.rs b/macula-rust-ffi/src/node_key.rs index 512f366..c210e56 100644 --- a/macula-rust-ffi/src/node_key.rs +++ b/macula-rust-ffi/src/node_key.rs @@ -84,6 +84,15 @@ impl FfiNodeKey { Ok(Arc::new(FfiNodeKey(Arc::new(key)))) } + /// The identity key in the key file at `path`, or, when nothing is + /// there, a new one saved there first. A file that does not load as a + /// key of `profile` is refused and left as it is. + #[uniffi::constructor] + pub fn load_or_create(path: String, profile: FfiProfile) -> Result, FfiError> { + let key = NodeKey::load_or_create(Path::new(&path), profile.into())?; + Ok(Arc::new(FfiNodeKey(Arc::new(key)))) + } + /// The identity key the platform's secure store holds under `service` /// and `account`, as [`save_to_keystore`](Self::save_to_keystore) put it. #[uniffi::constructor] diff --git a/macula-rust-ffi/tests/pool_ffi.rs b/macula-rust-ffi/tests/pool_ffi.rs index f8de07c..de7c43e 100644 --- a/macula-rust-ffi/tests/pool_ffi.rs +++ b/macula-rust-ffi/tests/pool_ffi.rs @@ -92,6 +92,10 @@ fn a_node_key_is_made_in_either_profile_and_survives_its_key_file() { let loaded = FfiNodeKey::load(path.to_string_lossy().into(), profile).unwrap(); assert_eq!(loaded.node_id(), key.node_id()); } + let fresh = dir.path().join("fresh.key").to_string_lossy().into_owned(); + let made = FfiNodeKey::load_or_create(fresh.clone(), FfiProfile::PqPure).unwrap(); + let again = FfiNodeKey::load_or_create(fresh, FfiProfile::PqPure).unwrap(); + assert_eq!(again.node_id(), made.node_id()); let missing = FfiNodeKey::load( dir.path().join("none").to_string_lossy().into(), FfiProfile::PqPure, diff --git a/plans/PLAN_RESOURCE_LEAK_HARDENING.md b/plans/PLAN_RESOURCE_LEAK_HARDENING.md deleted file mode 100644 index 70e4265..0000000 --- a/plans/PLAN_RESOURCE_LEAK_HARDENING.md +++ /dev/null @@ -1,293 +0,0 @@ -# PLAN_RESOURCE_LEAK_HARDENING.md - -**Status:** Survey complete — hardening not started -**Created:** 2026-09-12 -**Last Updated:** 2026-09-12 - -## Overview - -Read-only survey of `src/` (all 19 modules), `macula-rust-ffi/src/lib.rs`, -and the tests/examples that reveal usage patterns, for potential memory and -resource leaks — run as a cross-check against the just-completed survey of -the C# sibling (`macula-dotnet/plans/PLAN_RESOURCE_LEAK_HARDENING.md`). No -code was changed; every finding below is verified against the current tree -with file:line references, and quinn 0.11.11's own Drop semantics were -checked in the vendored source (`send_stream.rs:344-362`, -`recv_stream.rs:509-531`, `endpoint.rs:718-728`, `connection.rs:929-948`) -before anything was claimed released or retained. - -The headline result: **the C# survey's CRITICAL stream-slot leaks do not -exist in Rust.** quinn's own Drop impls release every dedicated stream -(dropped `SendStream` → FIN, dropped `RecvStream` → STOP_SENDING(0), last -`ConnectionRef` → implicit close), `OpenSessions` holds `Weak` handles -instead of strong ones, the inbound CALL queue is bounded at 64, both -crates forbid `unsafe` (`unsafe_code = "forbid"` in each Cargo.toml), and -UniFFI scaffolding owns all raw-pointer marshalling — there is no -hand-written `Box::into_raw`/`from_raw` anywhere. What remains is a set of -Rust-shaped resource-lifetime issues: abandoned detached handler tasks, -unobserved panics in fire-and-forget tasks, missing app-level stream -teardown on error paths, and FFI objects with no Drop-side protocol -signaling. - -General hygiene found GOOD and not repeated here: `Pool::close` aborts and -awaits every background task via `JoinSet::shutdown` before draining links -(pool.rs:551-562); `PooledLink` state is never held across a round trip; -`Waiting` (control_channel.rs:827-836) removes a call's reply slot on every -exit path; `transport::connect`'s local `Endpoint` drop is safe (quinn's -`EndpointRef` keeps the driver alive while connections exist); the CBOR -decoder caps nesting (`MAX_NESTING_DEPTH = 128`), validates declared -lengths before slicing, and caps list preallocation (cbor.rs:317, 366-411, -491); `frame::MAX_FRAME_BYTES` (16 MiB) caps every frame decode -(frame.rs:41, 1087-1138); direct-dial lease accounting releases on every -request outcome (direct_dial.rs:1437-1454). - ---- - -## Findings (ranked) - -### CRITICAL - -None. The three C# CRITICALs (F1-F3: dedicated streams never released, -consuming outbound stream slots until the connection dies) are **not -present**: every dedicated stream in this crate is owned by a `FrameStream` -whose quinn halves release the QUIC stream slot on drop. Verified against -quinn 0.11.11 source, not assumed. The residual gap is that this teardown -is bare (FIN + STOP_SENDING code 0) rather than an app-level -finish/abort — see F3, ranked MEDIUM for that reason. - -### HIGH - -#### F1. `serve_one_call_gated` timeout abandons the running handler task, detached -`connection.rs:978-993` (the `tokio::time::timeout(...).unwrap_or(...)` -wrap) + `connection.rs:1346` (`tokio::spawn(async move { handler(payload).await })`). - -When the serve timeout fires (or the caller cancels the future), the -in-flight `build_call_reply` future is dropped, which drops the -`JoinHandle` of the spawned handler task **without aborting it**. The -handler keeps running to completion in the background, detached, holding -whatever it captured (its `Arc` / `FfiCallHandler`, the -payload, any app state it moved in). A handler that hangs (waiting on a -network call, a lock, a blocking API) leaks its task forever; every -timed-out serve of a hung handler accumulates one detached task. An app -looping `serve_one_call` with short timeouts against a slow handler grows -background work without bound. The reply the detached handler eventually -produces is dropped as an unrouted frame, which is fine for the caller's -timeout contract — the leak is the task itself. - -Fix direction: on timeout, abort the handler task (`JoinHandle::abort()` -inside `build_call_reply`'s own timeout/guard) or run handlers under an -explicit watchdog, so a timed-out serve leaves no live task; add a test -with a hung handler asserting the task count returns to baseline. - -#### F2. Background tasks spawned without JoinHandle observation — a panic is unobserved, and a dead reader leaves a session that still reports live -`control_channel.rs:381` (reader task), `:382` (hand-off writer task), -`:960-967` (`Subscription::drop` → `runtime.spawn(remove_subscription)`), -`control_channel/drop_warning.rs:188-194` (interval closer), -`connection.rs:1346` (per-call handler). - -Every one of these spawns discards its `JoinHandle`, so a panic inside is -observed by nothing (the default panic hook prints; the `JoinError` is -never awaited). The reader task is the sharpest case: `SessionInner::is_live` -(connection.rs:351-353) checks only `end_reason` and `close_reason` — a -reader that panicked leaves both `None`, so the session still reports -live, still sits in `open_sessions::live()` (as the registry's `Weak` is -upgradable through the app's own still-held handles), and is handed out -for reuse while it can never route a frame again. Retained resources: the -whole `Channel` (writer mutex, pending-calls map, subscriptions) plus a -QUIC connection, for as long as any `Session` handle exists. - -Fix direction: retain and observe the reader/writer `JoinHandle`s (e.g. -store them in `Channel`, `end()` on a reader `Err(join)`), attach -observation continuations to the fire-and-forget spawns (`Subscription::drop`, -`drop_warning::record`), and treat a panic in `read` as a session end -(`SessionEndReason::StreamFailed`-shaped), which also drives the -`on_ended` unregister. - -### MEDIUM - -#### F3. Dedicated streams never receive an app-level finish/abort on any path -`content.rs:144-149` (`put`), `:180-185` (`get`), `:225-322` -(`put_block`/`put_manifest`/`get_block`/`get_manifest` error paths); -`stream.rs:202-229` (`open_on`, STREAM_OPEN write failure path); -`connection.rs:154-165` (the `abort_both`/`finish_and_stop_reading` -helpers that these callers never call). - -`content::put`/`get` and `StreamHandle::open_on` open a dedicated stream -and simply drop the `FrameStream` on success and every failure path — -never `finish_and_stop_reading` (success) or `abort_both` (failure). The -stream is released by quinn's Drop (FIN + STOP_SENDING(0)), so this is -**not** the C# slot-exhaustion leak — but the peer's only signal is a bare -code-0 teardown: a mid-transfer failure (hash mismatch, remote error, -timeout) is indistinguishable from a clean end of transfer, and the -protocol's own refusal code (`stream::REFUSED_STREAM = 2`) is used only on -the accept/refuse paths, never here. Consequence is protocol-level -mis-signaling and harder debugging on the station side, not local memory -growth. - -Fix direction: give `put_on`/`get_on`/`open_on` a teardown guard — -finish-and-stop on success, abort with an appropriate code on every error -return; same for `FrameStream::call`'s send-failure path. - -#### F4. `fetch_content` timeout drops a leased, dialed session mid-transfer without release or GOODBYE -`direct_dial.rs:601-614`: on the `Err(_)` branch of the fetch timeout the -`fetch(target, ...)` future — which owns the `StationTarget`, the lease, -and the in-flight `FrameStream` — is dropped wholesale. The dialed -session's lease is never released through `Leases::release`, and the -session is closed only when the last handle drops, i.e. abruptly via -`SessionInner::drop` (connection.rs:341-348): no GOODBYE, no bounded drain, -the station sees a bare connection close. Released, not leaked — but the -lease accounting is bypassed, and a request that the app still holds a -handle to (through a clone) survives past its deadline with the -connection torn out from under it. - -Fix direction: make the timeout branch release the target through the same -`run_then_release`/`close_last` path the non-timeout outcomes use, and -have dropped leases close leased sessions explicitly (GOODBYE) where a -runtime is available. - -#### F5. FFI wrapper objects have no Drop that performs protocol teardown -`macula-rust-ffi/src/lib.rs:1626-1654` (`FfiSubscription`), `:1662-1789` -(`FfiStream`), `:733` (`FfiSessionLease`), `:865` (`FfiSession`). - -UniFFI cannot await in `Drop`, so all four objects delegate teardown to -explicit methods (`close`, `abort`, `refuse`, `release`). A foreign -(Kotlin/Swift) caller that lets an object be GC'd without calling those -gets quinn's bare teardown (F3) plus: an **accepted** inbound stream -(`accept_stream`, lib.rs:1525-1540) that is never served and never -`refuse`d leaves the peer's `await_reply`/`recv` hanging until its own -timeout (no error, no refusal frame ever arrives), while the local stream -slot stays consumed for that whole window; a `FfiSession` GC'd without -`close` tears the connection down without GOODBYE (documented, but -every Ffi object makes it easier to hit); a `FfiSessionLease` GC'd without -`release` skips lease accounting exactly as in F4. Resources are -ultimately freed by Rust drops — this is protocol-lifetime hardening, the -FFI-shaped analog of the C# F6 finding. - -Fix direction: where a runtime handle is available, mirror -`Subscription::drop`'s pattern (spawn a teardown task from `Drop`); at -minimum add `close`/`abort` destructor guidance and an accepted-stream -watchdog that refuses never-served streams after a bound. - -### LOW - -#### F6. Frame/reader buffers grow to the frame cap and never shrink -`connection.rs:72-78` (`FrameStream.buf`), `connection.rs:178-196` -(`recv_frame`), `control_channel.rs:838-884` (reader `buf`). Each buffer -grows to at most `frame::MAX_FRAME_BYTES` (~16 MiB) and retains that -capacity for the rest of the session's life; the inbound CALL queue -(control_channel.rs:51) can simultaneously hold 64 payloads of up to -16 MiB each worst case. Bounded — the C# equivalent of `Envelope.MaxFrameBytes` -is present and respected — but a memory-pressure knob under large-frame -traffic. Consider releasing/shrinking `FrameStream.buf` on finish and -capping aggregate queued payload bytes. - -#### F7. Stale weak entries in `OpenSessions` when a session dies without `end()` -`open_sessions.rs:89-131`. Unregister happens through the `on_ended` -callback (connection.rs:515); if the reader task panics before `end()` -runs (F2's scenario), the weak entry survives until the next -`find`/`unregister` for that (identity, station) pair. Harmless (weak, ~80 -bytes), bounded by pairs ever used — but prune-on-find is the only -recovery, and it should also run when `SessionInner::drop` fires outside -`end`. - -#### F8. `KeyPair::save` leaves a `.tmp` file on failure before rename -`identity.rs:160-163` — same shape as the C# survey's -`KeyPair.Save` LOW: a write that fails at `fs::write`/`set_permissions`/ -`rename` leaves the sibling `.tmp` behind. Delete on error. - -#### F9. `StreamHandle::accept`'s shared-deadline loop can be monopolized by refused stream-open floods -`stream.rs:244-265` — the same note as the C# survey's LOW for -`AcceptAsync`: a peer flooding STREAM_OPENs that all get refused keeps the -loop consuming the one total deadline. Refused streams are properly -aborted (`refuse` → `abort_both(REFUSED_STREAM)`, stream.rs:109-112), so -this is a liveness/budget concern, not a leak. - -#### F10. `Subscription::drop` spawn can silently fail outside a runtime -`control_channel.rs:954-969` — `try_current()` returning `Err` falls back -to the retain path, which is correct and documented; the `spawn` path's -fire-and-forget JoinHandle is part of F2. No action beyond F2's. - ---- - -## Cross-check against the dotnet survey (record for completeness) - -| dotnet finding | Rust status | -|----------------|-------------| -| F1 `ContentTransfer` never releases its stream | **Not present as a leak** — quinn Drop releases the QUIC stream (FIN + STOP_SENDING(0), verified in quinn 0.11.11 `send_stream.rs:344-362`, `recv_stream.rs:509-531`). Residual app-level teardown gap: F3 here. | -| F2 `StreamHandle.AcceptAsync` abandons stream on non-refusal failures | **Not present as a leak** — `open_inbound`'s `Inbound::Failed` and `accept`'s timeout both drop the `FrameStream`, which quinn releases (stream.rs:272-303, 250-263). Refusal paths use the proper `REFUSED_STREAM` abort. | -| F3 `StreamHandle.OpenAsync` leaks on STREAM_OPEN write failure | **Not present as a leak** — same quinn Drop release on the `stream.rs:222` error path. | -| F4 `OperationCanceledException` misclassified as timeout | **Not present** — Rust's typed errors keep session-end (`SessionEnded`) and timeout (`Timeout`) distinct; `tokio::time::timeout` only errors on its own timer. The timeout-drop *consequence* (abandoned work) surfaces as F1 here. | -| F5 Untrusted `manifest.Size` drives unbounded allocation | **Not present — deliberately addressed**: `get_on` grows the buffer only from individually hash-verified chunks (content.rs:202-219), and `from_wire` rejects `chunk_size: 0` and mismatched `chunk_count` (manifest.rs:352-368, 408-413). | -| F6 No `IDisposable` safety net on handles | **Resource side not present** — quinn's Drop *is* the safety net; the app-code side (no abort codes on drop) is F3/F5 here. | -| F7 Unbounded task fan-out per inbound CALL | **Mostly not present** — inbound queue bounded at 64 (control_channel.rs:51); the per-call handler task is awaited inline (connection.rs:1346), not fire-and-forget. The abandoned-on-timeout case is F1. | -| F8 `EventDedup` growth between sweeps | **Not present** — no event-dedup map exists in this crate (verified by search). | -| F9 Fire-and-forget close with unobserved fault | **Present, Rust-shaped** — F2 here (reader/writer/subscription-removal/drop-warning spawns, all JoinHandle-discarded). | -| F10 Publisher CTS ownership / unobserved callbacks | **Not present** — no CTS pattern; `run_publisher` (connection.rs:1050-1095) is fully awaited; fact-publish failures are deliberately discarded as in the reference. | -| F11 Dead subscriptions retained by ended channel | **Not present materially** — `end()` drops every entry's event sender (control_channel.rs:626-628); remaining `Entry` strings are bounded by session lifetime. | -| F12 Static `OpenSessions` registry retains undisposed sessions | **Not present** — registry holds `Weak` (open_sessions.rs:77) and unregisters via `on_ended` (connection.rs:515); dropped sessions are unreachable by `find`. Residual hygiene: F7. | -| LOW `.tmp` file after failed `KeyPair.Save` | **Present** — F8. | -| LOW accept-loop budget under refusal floods | **Present** — F9. | - -**Rust-specific additions not in the dotnet survey:** F1 (detached handler -tasks — no equivalent "abandoned task on timeout" shape in the C# list), -F2's reader-panic-leaves-session-"live" consequence, F4 (lease bypass on -fetch timeout), F5 (UniFFI objects without Drop-side protocol teardown), -F6 (buffer retention). Also verified clean: `unsafe` is forbidden in both -crates, so the "unsafe code in ffi leaking across the boundary" item from -the survey list has no hand-written counterpart — UniFFI's generated -scaffolding owns all marshalling. - ---- - -## Phases - -- [ ] Phase 1 — Detached-task closure (F1, F2): abort handler tasks on - serve timeout; retain and observe the reader/writer JoinHandles; - panic in the reader ends the session (drives `on_ended` unregister). - Test: N timed-out serves of a hung handler leave zero live tasks. -- [ ] Phase 2 — Dedicated-stream teardown (F3, F4): finish/abort guards on - `content::put_on`/`get_on` and `StreamHandle::open_on`; lease-aware - release on the `fetch_content` timeout branch. Test: repeated - put/get and open-failure cycles show no station-side open-stream - growth and correct RESET codes on failure. -- [ ] Phase 3 — FFI lifetime (F5): Drop-side teardown where a runtime is - available (spawn-on-drop like `Subscription::drop`), destructor - guidance, and a bounded watchdog refusing never-served accepted - streams. -- [ ] Phase 4 — Hygiene (F6-F10): buffer release/caps, stale-weak prune - on drop, `.tmp` cleanup, accept-loop budget. - -## Files to Create/Modify - -| File | Purpose | Status | -|------|---------|--------| -| `src/connection.rs` | F1 handler-task abort on timeout, F2 reader/writer JoinHandle observation, F6 buffer release | Not started | -| `src/content.rs` | F3 finish/abort teardown on `put_on`/`get_on` | Not started | -| `src/stream.rs` | F3 teardown on `open_on`/accept error paths, F9 accept budget | Not started | -| `src/direct_dial.rs` | F4 lease release + close on fetch-timeout drop | Not started | -| `src/control_channel.rs` | F2 spawned-task observation, F6 queue byte cap, F10 | Not started | -| `src/control_channel/drop_warning.rs` | F2 observation of the interval-closer spawn | Not started | -| `src/open_sessions.rs` | F7 prune on `SessionInner` drop | Not started | -| `src/identity.rs` | F8 `.tmp` cleanup on save failure | Not started | -| `macula-rust-ffi/src/lib.rs` | F5 Drop-side teardown, accepted-stream watchdog | Not started | -| `tests/` (new leak-regression tests) | Prove F1-F4 fixes: task-count stability, stream-count stability, correct abort codes | Not started | - -## Success Criteria - -- [ ] A live-session stress test (N sequential `content::put`/`get` calls, - interleaved with induced failures) shows no growth in station-side - open-stream count, and failure teardown carries a non-zero - RESET_STREAM/STOP_SENDING code. -- [ ] `serve_one_call` with a hung handler, timed out 1000x, leaves zero - additional live tasks (task count stable after each cycle). -- [ ] A panicking reader task ends the session (`end_reason` set, - `on_ended` ran, `open_sessions` no longer finds it) instead of a - live-reporting zombie. -- [ ] `fetch_content` timeout on a dialed session releases the lease and - closes the session with GOODBYE, not a bare drop-close. -- [ ] A foreign-side GC of `FfiStream`/`FfiSubscription`/`FfiSessionLease` - produces the same teardown (abort frame / UNSUBSCRIBE / lease - release) as the explicit close/abort/refuse methods. -- [ ] All tests green (`cargo test` in workspace root and - `macula-rust-ffi`), clippy clean under the repo's deny config, and - `unsafe_code = "forbid"` still holds in both crates. diff --git a/plans/PLAN_WIRE_PROTOCOL.md b/plans/PLAN_WIRE_PROTOCOL.md deleted file mode 100644 index 0b6b93a..0000000 --- a/plans/PLAN_WIRE_PROTOCOL.md +++ /dev/null @@ -1,1099 +0,0 @@ -# Macula Wire Protocol — Spec Extracted for a Rust SDK Port - -**Status:** Reference spec, extracted from source. Not a build plan yet. -**Created:** 2026-08-28 -**Repo renamed 2026-08-28:** `macula-mobile` → `macula-rust`. This is a -Rust port of macula's *SDK* half (the client/leaf side — see -`macula/CLAUDE.md`'s own SDK-vs-Relay split), not the relay/station. Mobile -(iOS/Android via UniFFI) is the flagship, driving consumer and the reason -this work started, not the ceiling on it — the same core is equally usable -from a future WASM build, CLI tool, or any other non-BEAM Rust consumer, -with no code shaped specifically around "mobile" below the UniFFI binding -layer itself. -**Scope constraint:** macula-station cannot change. Everything below describes -the wire contract as it exists today so a client can be built against it -unmodified. - -**Why this exists:** so a non-BEAM Rust consumer — a phone first — can hold -a QUIC session with an unmodified macula-station and speak its real -application primitives (pubsub, RPC, capability advertise), without -macula's own Erlang code changing at all. - -This is a BUILD artifact (a wire-format spec extracted from existing, -shipped, tested source), not a CLAIM about the world — nothing here needs -an adversarial gate. It needs to be *correct against the source*, which is -why every section below is traced to specific files and line ranges in -`macula-io/macula` at v10.10.0 rather than reconstructed from memory. - -Source files read in full for this spec: `native/macula_quic/{Cargo.toml, -src/cert.rs, src/config.rs}`, `native/macula_cbor_nif/src/deterministic.rs`, -`src/identity/macula_identity.erl`, `src/peering/{macula_protocol_types.erl, -macula_frame.erl, macula_peering_conn.erl, macula_bolt4.erl, -macula_source_route.erl}`, `src/content/macula_manifest.erl`, -`src/macula_content_transfer.erl`, `src/macula_upload.erl`, -`src/macula_pusher.erl`, `src/macula_download.erl`, `src/macula_stream.erl`, -`src/macula_streamer.erl`, `src/macula_stream_sink.erl`, plus lines 890-964 -of `src/client/macula_client.erl` (identity-resolution/puzzle-lifecycle -context). Skimmed for scope only (not needed for a client, station/config-side): -`macula_tls.erl`, `macula_peering.erl`, `macula_quic.erl`. Not read, role -inferred from siblings (§12.3): `macula_feeder.erl`, -`macula_content_transfer_registry.erl`, `macula_stream_local.erl`, -`macula_streamer_sup.erl`, `macula_feeder_sup.erl`, `macula_download_sup.erl`. -Not yet read: the rest of `macula_client.erl` (1376 lines total — only the -identity-resolution section was needed so far), `macula_record_cbor.erl` -(corroborating source for the CBOR codec, not primary), `macula_crypto_nif`'s -`grind_puzzle` implementation (not needed — algorithm fully specified from -the Erlang side). Confirmed dead: -`macula_protocol_types.erl`, `macula_protocol_encoder.erl`, -`macula_protocol_decoder.erl` — an unreferenced legacy (V1, msgpack/byte-tag) -scheme, superseded entirely by `macula_frame.erl` ("Macula V2"). Ignore all -three; nothing in the live peering connection state machine calls them. - ---- - -## 1. Transport layer - -- **Engine:** `quinn` 0.11 + `rustls` 0.23 (`ring` backend), driven from - Erlang via a `rustler` NIF (`native/macula_quic`). Despite the "HTTP/3 - mesh" branding elsewhere in the docs, this is **raw QUIC**, not real - HTTP/3 — there is no `h3` crate dependency anywhere in `macula_quic`'s - `Cargo.toml`. -- **ALPN:** `"macula"` (single string, `native/macula_quic/src/config.rs:13` - default). A client MUST negotiate this ALPN, not `h3` or anything else. -- **Framing on top of QUIC:** one long-lived bidirectional "control stream" - per connection carries the handshake and all application frames after - it. Separate QUIC streams ("dedicated streams") are opened per streaming - RPC session or per content-transfer session — see §7. - -A Rust client depending on the same `quinn`/`rustls` combination macula -already trusts should be wire-compatible at the transport level with zero -station-side changes. Dial directly on `quinn` — see §11.4 for why `iroh` -was considered and dropped: macula's edges are dial-out only, and macula -already owns discovery/gossip/pubsub/identity, so Iroh's actual -distinguishing features (NAT traversal, its own discovery, its own -gossip/doc-sync) would compete with macula's stack rather than fill a -gap in it. - -## 2. Identity and trust model - -Every peer's identity **is** an Ed25519 keypair. There is no separate -account system at the transport layer. - -- **Certs:** self-signed Ed25519 leaf certs generated per node - (`native/macula_quic/src/cert.rs`, using `rcgen`). No CA chain, no - DNS-anchored trust, by design — the doc comment says so explicitly. -- **TLS-layer verification**, chosen per dial (`macula_peering_conn.erl` - `dial_trust_opts/1`, lines 785-812): - - `verify_pubkey => NodeId` (32-byte Ed25519 pubkey): pins the server - cert's SubjectPublicKeyInfo to that exact key via a custom - `rustls::client::danger::ServerCertVerifier` - (`cert.rs::PubkeyPinVerifier`, ~line 140). Used when the dialer already - knows the peer's identity (DHT records, pre-shared relay identities). - - `verify => webpki` (default since macula 5.0.0): standard CA-bundle + - hostname validation (`webpki-roots` crate). Used for bootstrap-style - dials by hostname where the peer's Ed25519 identity isn't known yet. - - `verify => none`: skips TLS verification entirely — dev/lab only, logs - a warning. -- **Application-layer verification, independent of the above:** the - CONNECT/HELLO handshake frame itself carries the peer's self-claimed - `node_id` and is Ed25519-signed. `macula_frame:verify/2` checks the - signature proves the sender holds the private key for that `node_id` — - this is checked **regardless of which TLS trust mode was used**. If the - dialer additionally set `expected_node_id`, `bind_peer_identity/2` - (`macula_peering_conn.erl:481`) rejects the handshake unless the - verified frame identity matches, closing the gap where TLS-layer trust - and application-layer identity could otherwise diverge (e.g. under - `pin_tls_cert => false`, where a peer's TLS is terminated by an - unrelated PKI). - -**For a mobile client dialing a known macula-station:** use -`verify_pubkey` with the station's known Ed25519 identity, matching what -DHT-resolved or pre-configured station records already give you, rather -than `webpki`. - -**Empirical finding, 2026-08-28 — confirmed against a live production -station, not assumed:** `macula-station-frankfurt` (`macula.io`, part of -the 7-box demo fleet) presents a **3-certificate RSA chain** (SPKI OID -`1.2.840.113549.1.1.1`), not a self-signed Ed25519 identity cert. That's -macula's *other* documented trust mode (`verify => webpki`, "public-IP -path with Let's Encrypt-anchored certs"), confirmed working end-to-end -from `macula-rust` (`tests/live_station.rs`): full QUIC/TLS handshake -completes, ALPN negotiates as `"macula"` exactly per spec, CA-chain -validation against `webpki-roots` succeeds. Pubkey-pinned trust is fully -implemented and unit-tested (`src/cert.rs`, against a synthetic cert — -see its test module), but **no box in the currently-reachable demo fleet -happens to be configured that way**, so it hasn't been exercised live. -Whoever configures the *target* station for a real deployment decides -which trust mode applies — this crate needs to support both regardless, -which it does. - -**Operational note for reaching this fleet specifically:** the bare -`macula.io` hostname has an A record but genuinely no AAAA record, while -the station's actual QUIC listener is bound to a specific IPv6 address -with no relationship to that A record — dialing `macula.io:4433` directly -resolves to a real, reachable IPv4 address with nothing listening, and -every packet vanishes silently (indistinguishable from a firewalled port -from the client side alone; confirmed via `ss -ulnp` on the box itself, -not guessed). `station-de-frankfurt.macula.io` is the name that actually -resolves to the listener. Matches the DNS-repoint gotcha already on file -in project memory (`reference_demo_fleet_boxes`) — confirmed still true. - -## 3. Connection lifecycle (state machine) - -From `macula_peering_conn.erl` (`gen_statem`), module doc lines 1-11: - -``` -client: connecting → handshaking → connected → draining → (terminate) -server: awaiting_start → handshaking → connected → draining → (terminate) -``` - -Client-side flow a Rust implementation needs to reproduce: - -1. Dial QUIC to `(host, port)` with ALPN `["macula"]` and the trust mode - from §2. (`do_connect/1`, line 785.) -2. Open one bidirectional stream on the connection (the control stream). -3. Send a **signed CONNECT frame** (§5) on that stream. -4. Start a 30-second handshake timeout (`HANDSHAKE_TIMEOUT_MS`, line 181). - Its most common real-world trigger, per the code comment, is a protocol - version mismatch (bytes accumulate but never form a valid frame) — a - Rust client that gets this wrong will just silently time out, not get - an explicit error frame. -5. Receive and verify the peer's **signed HELLO frame**. If - `accepted := true`, absorb peer info (`node_id`, `station_id`, - `realms`, `capabilities`) and transition to `connected`. If - `accepted := false`, the connection is refused (`refusal_code` present) - — terminate. -6. In `connected`, every frame arriving on the control stream is - length-prefixed CBOR (§4) parsed via `parse_stream/1` and dispatched by - `frame_type`. Every outbound application frame is auto-signed if not - already signed (`ensure_signed/2`, line 771) and written to the same - control stream. Frames may be batched into one write (up to 64 queued - sends coalesced, line 757) — purely a sender-side optimization, no - wire implication. -7. `GOODBYE` (§5) + close, or the peer's own stream/connection closure, - moves to `draining` (5-second grace timeout) then terminates. - -Note for implementers: the actual QUIC "closed" event the Rust NIF *could* -send is never wired up on the Erlang side (dead code, confirmed by -comment at `macula_peering_conn.erl:329`) — what a real disconnect looks -like on the wire is a `stream_closed` or `peer_send_shutdown` condition at -the QUIC-stream level, not a distinct "connection closed" frame. A Rust -client should treat stream-level close/reset the same way. - -## 4. Wire frame codec - -From `macula_frame.erl`, module doc lines 1-28. - -``` -<> -``` - -`Cbor` is the **RFC 8949 §4.2.1 deterministic encoding** of a single CBOR -map. `Length` is the byte length of `Cbor` alone (not including itself). -`MAX_FRAME_BYTES = 0xFFFFFF` (16 MiB) — a frame at or under 4 bytes header -is either read whole or the caller is told how many more bytes are needed -(`decode/1`, lines 1546-1557; three-way return: `{ok, Frame, Rest}`, -`{more, N}`, `{error, Reason}`). - -**⚠ Deterministic/canonical CBOR is load-bearing for correctness, not -just a style choice.** Every frame's Ed25519 signature is computed over -the canonical CBOR bytes of the unsigned frame (§5). If a Rust CBOR -encoder produces different bytes for the same logical map (different key -ordering, non-minimal integer encoding, etc.), signatures will not -verify against station-produced frames and vice versa. - -**RESOLVED — `ciborium` is not involved at all, and that's good news, not -a gap.** `macula_cbor_nif` has two separate code paths -(`native/macula_cbor_nif/src/`): `nif_pack`/`nif_unpack` go through -`ciborium::value::Value` and are genuinely non-deterministic (not what -the wire uses). `pack_deterministic`/`unpack_deterministic` — what -`macula_frame.erl` actually calls — live in a **separate, hand-rolled -encoder** (`deterministic.rs`, 410 lines) that bypasses `ciborium` -entirely and operates directly on `rustler::Term`. Its own doc comment -says it "mirrors `macula_record_cbor.erl` byte-for-byte" and the two are -kept in sync by a differential test -(`test/macula_cbor_deterministic_diff_tests.erl`). This means the exact -canonical algorithm is fully known, small, and directly portable — -nothing to "verify against the RFC," just a mechanical Rust-to-Rust -transcription of an already-correct 200-line core: - -- **Integers:** non-negative → major 0; negative → major 1, encoded value - `-1 - N`. Both use **minimal-length encoding**: inline if ≤23, else the - smallest of 1/2/4/8 extra bytes that fits (AI 24/25/26/27). Range: - positive up to `u64::MAX`; negative down to `-(2^64)` (via `i128` - internally, since plain `i64::MIN` is one bit short). Anything outside - that range is a hard encode error, not silent bignum handling. -- **Binary** → major 2 (byte string), raw bytes, unchanged. -- **`{text, Binary}`** → major 3 (text string), bytes used **as-is, no - UTF-8 validation** (matches the Erlang encoder's own leniency — don't - add validation a Rust port that isn't there in the source). -- **Atom (not `null`)** → major 3, via the atom's own UTF-8 name. This - NIF encodes atoms directly to text on the way out, but **on decode - every major-3 value always comes back as `{text, Binary}`, never a bare - atom** — atom reconstitution (`binary_to_existing_atom`) happens one - layer up, in `macula_frame.erl`'s own `from_wire_envelope/1` (§ above). - A Rust port has no atom-table-exhaustion risk to defend against, so - this two-layer split collapses to nothing: just decode major-3 as a - `String`/`&str` and match it against the fixed vocabulary in §6 - directly. -- **List** → major 4 (array). -- **Tuple**: the **only** encodable tuple shape is `{text, Binary}` — - anything else is a hard encode error. There is no general tuple - encoding. -- **Map** → major 5. Keys are sorted by the **bytewise lexicographic - order of their own already-encoded bytes** (encode each key - independently first, then sort the `(key_bytes, value_bytes)` pairs by - `key_bytes` using plain byte-vector `Ord`, then concatenate). This is - the one rule a naive implementation is most likely to get wrong — - sorting by the *original* key representation instead of its *encoded* - bytes will diverge from station output for keys of different CBOR - major types. -- **`null` (Erlang `undefined`)** → major 7, AI 22 (`0xF6`). -- **Float → ALWAYS binary64** (major 7, AI 27, `0xFB` prefix) on encode, - regardless of whether the value would round-trip in fewer bits. This is - a **deliberate divergence from RFC 8949's own canonical-form - recommendation** (which prefers the shortest float width that - round-trips) — done so the byte derivation is independent of platform - float encoding. A generic "canonical CBOR" crate that follows the RFC's - shortest-float rule instead of this will silently produce - non-matching, non-verifying bytes. Decode accepts binary16/32/64 for - interop, converting all to `f64`. -- **Decode rejects major type 6 (tags) outright** — not supported at all. - Major 7 only supports `null` and the three float widths; no booleans, - no "undefined" simple value, nothing else. Duplicate map keys on decode - are last-write-wins, not an error. -- Every decode path is panic-free by construction (explicit bounds checks - throughout, no `unwrap`/`expect`/panicking slice index) — worth - matching in a Rust port that will also be parsing untrusted - network input. - -Net effect: this open item is closed. Define a small Rust `Value` enum -mirroring these variants (`UInt`, `NegInt`, `Bytes`, `Text`, `List`, -`Map`, `Null`, `Float`) and transcribe `encode_value`/`decode_one` from -`deterministic.rs` directly — no external crate needed for this part at -all. - -**Atom ↔ wire-string mapping** (`to_wire/1` / `from_wire_envelope/1`, -lines 1855-1909): every Erlang atom (frame type names, field names like -`frame_type`, `capabilities`, enum values like `alive`/`suspect`) encodes -as a CBOR text string (major type 3) on the wire — there is no compact -integer tag scheme in the *live* protocol (that was the legacy -`macula_protocol_types.erl` design; dead, see header). A Rust -implementation needs a fixed table mapping each known atom name to/from -its literal string spelling — every such name is enumerated in §6 below. -Erlang's `undefined` maps to CBOR `null` and back. Plain binaries -(signatures, node IDs, payloads, nonces) stay as CBOR byte strings (major -type 2), never text. - -**Signing domains** (Ed25519, `macula_identity:sign/2`): -| Domain separator | Covers | -|---|---| -| `"macula-v2-frame\0"` | Every frame's own `signature` field, over the canonical CBOR of the frame with `signature` and `publisher_sig` stripped (`canonical_unsigned/1`, line 1633). | -| `"macula-v2-swim-update\0"` | Each individual SWIM piggyback update's own `signature`, over the update map minus `signature` (`canonical_swim_update/1`, line 807). | -| `"macula-v2-event-pub\0"` | `publisher_sig` on PUBLISH/EVENT frames — a **separate**, end-to-end signature over just `(topic, realm, publisher, seq, payload)`, independent of frame type, so it survives PUBLISH→EVENT conversion across relay hops (§6.6). | - -Domain separation is deliberate and enforced by construction: a signature -valid under one domain must never be replayable as a signature under -another. - -## 5. Handshake frames (CONNECT / HELLO / GOODBYE) - -`connect_spec()` (`macula_frame.erl:180`): - -| Field | Type | Notes | -|---|---|---| -| `node_id` | 32 bytes | Ed25519 pubkey, the connecting identity | -| `station_id` | 32 bytes | for a plain peer/daemon dial, `send_connect/2` sets this equal to `node_id` | -| `realms` | list of 32-byte pubkeys | realms this identity claims membership in | -| `capabilities` | non-neg integer | bitmask, negotiated in HELLO | -| `puzzle_evidence` | 32 bytes | `SHA-256(node_id)` — see the dedicated callout below. Applies to **every** CONNECT, edge clients included, not just station-to-station peering. | -| `addresses` | optional list of maps | | -| `site` | optional map | | -| `endorsements` | optional list | realm-membership endorsement records (ties into HyParView admission, §6.3) | - -**⚠ The puzzle is an identity property, not a per-connection cost — and -skipping it fails silently.** From `macula_identity.erl` (177 lines) and -`macula_client.erl` (lines 928-952): - -- `puzzle_evidence(Pub)` is just `crypto:hash(sha256, Pub)` — a plain, - deterministic hash of the node's own 32-byte pubkey. No nonce, no - per-connection computation. -- The actual proof-of-work happens **once, at identity creation**: - `macula_identity:generate(#{puzzle => true})` grinds fresh Ed25519 - keypairs (via the `macula_crypto_nif:grind_puzzle/1` NIF) until one's - pubkey hash has at least `N` leading zero bits (S/Kademlia Sybil - defense — mints an identity expensively, not a connection). Default - `N = 8` (`?DEFAULT_PUZZLE_DIFFICULTY`), configurable via - `application:get_env(macula_identity, puzzle_difficulty, 8)`. The code - comment states grinding at the default is sub-millisecond. -- `puzzle_valid(Pub, N)` — the check any station runs — is just - "hash + leading-zero-bit check," equally trivial. -- **Every station checks this on every CONNECT/HELLO, for every kind of - dialer, not only station-to-station peering.** Confirmed directly: - `macula_client.erl` — the ordinary leaf SDK any daemon uses — defaults - to a puzzle-hardened identity specifically because, quoting the source - comment, "this identity is exactly what every station's - `puzzle_enforcement_mode/0` checks on CONNECT/HELLO." -- **Real incident, cited in the source (2026-08-21):** a client connected - with an *unhardened* identity. The QUIC/TLS connection reported - healthy, `subscribe/5` returned `{ok, _}` — and the station silently - rejected the HELLO at the application layer. Result: a link that looked - fully healthy delivered zero events for over an hour before the missing - puzzle was identified as the cause. **A mobile client that skips this - will exhibit exactly that failure mode**, and will be far harder to - diagnose without Erlang-side introspection. Do not skip it, and don't - bury the identity-generation step where a future implementer might - reach for the cheap `generate()` instead of `generate(#{puzzle=>true})`. -- **Empirical caveat, 2026-08-28 — tested directly, not assumed.** Against - the live `macula-station-frankfurt` (`macula-rust`'s - `tests/live_station.rs`), an **unhardened** identity was accepted - (`accepted = true`), not rejected — contradicting the incident above. - `macula-rust`'s own puzzle-evidence computation is independently - verified byte-for-byte against real Erlang `crypto:hash/2` output, so - this isn't a client-side computation bug; it means either this - particular dev-fleet station has enforcement disabled/lenient (it's - documented elsewhere as throwaway dev infra, not production), the - deployed image predates the enforcement described above, or - enforcement is scoped to a condition a plain CONNECT doesn't trigger. - Which one is true is a `macula-station`-side question, not chased here. - **Grind the puzzle regardless** — the cost is negligible and it's - unambiguously the documented, intended behavior; this caveat is a fact - about one dev station's current configuration, not license to skip it. - -**Lifecycle for the mobile port:** grind once — at first run/onboarding, -not per connection — persist the resulting keypair in secure device -storage (Keychain on iOS, Keystore on Android; the Erlang side's analog -is an atomic, 0600-permission local file write via `macula_identity:save/2`), -and reuse that same identity for every subsequent CONNECT. Never re-grind -per connection; `resolve_identity/1` in `macula_client.erl` is written -specifically to avoid that (`maps:get/3`'s default argument evaluates -unconditionally, so a naive lookup-with-default would grind on every -call even when an identity already exists — the source works around this -with an explicit `maps:find/2` check first). - -`hello_spec()` mirrors `connect_spec()` plus `accepted` (bool), -`negotiated_capabilities`, optional `refusal_code`. - -`goodbye(Reason, Detail)`: `reason` (atom, e.g. `normal`/`error`/`timeout`) -+ optional `detail` (binary). - -Every frame carries a common envelope from `base/2` (line 1621): -`version` (currently `2`), `frame_type` (atom), `frame_id` (UUIDv7), -`sent_at_ms`, `capabilities`, plus `realm`/`call_id`/`source_route` set to -`null` unless the specific frame type populates them. - -## 6. Full frame-type catalogue (the "application primitives") - -All from `macula_frame.erl`'s `frame_type()` union (lines 155-174) — -this is authoritative; ignore the differently-named, differently-scoped -message list in the dead `macula_protocol_types.erl`. - -### 6.1 Control -`connect`, `hello`, `goodbye` — §5. - -### 6.2 SWIM membership (`swim_ping`, `swim_ack`, `swim_suspect`, -`swim_confirm`) -Ping/Ack carry `round`, `incarnation`, optional `piggyback` (list of -signed `swim_update` maps: `target`, `state` ∈ -`alive|suspect|confirmed_failed`, `incarnation`, `observed_at`, `by`, -`signature`). Ack additionally carries `responder`. Suspect/Confirm carry -`target`, `target_incarnation`, `suspected_by`, `ttl` (decremented per -rebroadcast). No `swim_ping_req` in the live protocol (present in the -dead legacy catalogue only). - -### 6.3 Kademlia DHT (`ping`, `pong`, `find_node`, `nodes`, `find_value`, -`value`, `store`, `store_ack`, `replicate`, `replicate_ack`) -`ping`/`pong` carry a 16-byte `nonce`. `find_node` carries `key` (32 -bytes), `origin` (32-byte pubkey), `depth`. `nodes` returns a list of -`station_ref()`: `node_id`, `station_id`, `addresses`, `tier` (0-4), -`asn` (optional), `country` (2-byte ISO code), `last_seen_at`. `store`/ -`replicate` carry an opaque `macula_record:m_record()` (encoded -separately by `macula_record:encode/1`, not covered in this pass — needed -before DHT put/get can be ported). - -### 6.4 RPC (`call`, `result`, `error`) -This is the primitive a mobile client most needs early. `call_spec()` -(line 322): `call_id` (16 bytes), `procedure` (binary, e.g. -`"my.app.get_user"`), `realm` (32 bytes), `payload` (arbitrary CBOR-able -term), `deadline_ms`, `caller` (32-byte pubkey), optional -`source_route` (opaque binary, §8), optional `retry_budget`, optional -`ucan_token` (capability token for gated procedures). `result_spec()`: -`call_id`, `payload`, `responded_by`, optional -`source_route_reverse`. `call_error` uses the BOLT#4 taxonomy (§9): -`call_id`, `code` (0-255), `reported_by`, optional `detail`, -`offending_hop`, `source_route_partial`. - -**⚠ `procedure` (and `topic` in §6.8, and `detail` on ERROR/GOODBYE) are -`binary()` on the wire — a raw byte string (CBOR major 2), NOT text -(major 3).** Easy to get backwards, since most other string-ish fields -(`frame_type`, `reason`, `delivered_via`) really are atoms and do encode -as text. Caught by `macula-rust`'s own differential-vector tests: a -hand-built CALL frame using text encoding for `procedure` produced a -completely different (still validly-formed, silently wrong) signature -from the reference — see that crate's `src/frame.rs` for the fix and the -byte-level trace that found it. - -**Live-verified, 2026-08-28** (`macula-rust`'s -`tests/live_station.rs`): a full CALL/RESULT-or-ERROR round trip against -`macula-station-frankfurt` — signed CALL out, signed ERROR back -(`unknown_next_peer`, correctly correlated by `call_id`) for a -deliberately-nonexistent procedure name. - -### 6.5 HyParView membership overlay (`hyparview_join`, -`hyparview_forward_join`, `hyparview_neighbor`, `hyparview_disconnect`, -`hyparview_shuffle`, `hyparview_shuffle_reply`) -JOIN/FORWARD_JOIN/NEIGHBOR each optionally carry a signed -`macula_record:m_record()` admission endorsement — a realm requiring -admission-gated JOIN must reject a JOIN missing it. Likely not needed for -a leaf mobile client connecting to one known station; relevant mainly for -station-to-station overlay membership. - -### 6.6 Plumtree gossip (`plumtree_gossip`, `plumtree_ihave`, -`plumtree_graft`, `plumtree_prune`) -Epidemic broadcast tree primitives, keyed by `realm` + 16-byte `msg_id` + -`round`. Also station-to-station territory, not a leaf client concern for -v1. - -### 6.7 Overlay relay (`overlay_relay`) -Envelope wrapping an already-encoded HyParView/Plumtree frame with an -explicit target `peer`, so a station forwards it to whichever other -connection authenticates as that peer. Opaque `payload`; the relaying -station never decodes it. Not a leaf client concern. - -### 6.8 PubSub (`publish`, `subscribe`, `unsubscribe`, `event`) -The other primitive a mobile client needs early. `publish_spec()`: -`topic`, `realm` (32 bytes), `publisher` (32-byte pubkey), `seq`, -`payload`, `published_at_ms`, optional `ttl_ms`, optional -`publisher_sig` (the separate end-to-end signature, §4). `subscribe`: -`topic`, `realm`, `subscriber`, optional `filter`, optional `options`. -`event` is what a subscriber actually receives: same shape as `publish` -plus `delivered_via` ∈ `plumtree|dht|direct`. A relay station copies -`publisher_sig` verbatim from PUBLISH onto the EVENT(s) it fans out, so a -receiving mobile client can verify authenticity against the *original -publisher*, independent of which station relayed it. - -**Live-verified, 2026-08-28** (`macula-rust`'s -`tests/live_station.rs`): SUBSCRIBE → PUBLISH → EVENT against -`macula-station-frankfurt`, answering a question the spec had left open -— **yes, a subscriber receives its own publish** (`delivered_via = -"direct"`), essentially instantly on this fleet. Not guaranteed to -generalize to every delivery path (`plumtree`/`dht` weren't exercised), -but confirms the direct case works end-to-end, wire format included. - -### 6.9 RPC advertise (`advertise`, `unadvertise`) — frame types BUILT 2026-08-28 -A peer registers itself as the handler for `procedure` under `realm` on -its own connection; the station routes inbound CALLs for that procedure -back over that connection. Tombstoned on UNADVERTISE or disconnect. -Needed if a mobile client wants to *expose* an RPC procedure, not just -call one. Frame construction (`src/frame.rs`'s `AdvertiseSpec`/ -`UnadvertiseSpec`) is built and byte-verified; `Session::advertise`/ -`unadvertise` (`src/connection.rs`) send them. Consumed today by the -streaming provider role (§13.2, live-verified) — unary CALL routing to -an advertised procedure (accepting an inbound CALL on the control stream -and replying with RESULT/ERROR, mirroring -`macula_station_link.erl`'s `handle_inbound_call`) is not built yet; -nothing in this crate has needed to serve unary RPC so far, only -streams. - -### 6.10 Streaming RPC (`stream_open`, `stream_data`, `stream_end`, -`stream_error`, `stream_reply`) -`stream_open` mirrors `call`'s auth/routing shape (`deadline_ms`, -`caller`, `source_route`) plus `stream_id` (16 bytes) and `mode` ∈ -`server_stream|client_stream|bidi`. Runs on its own dedicated QUIC -stream, not the control stream — see §7. `stream_data` carries `seq` + -`body` with `encoding` ∈ `raw|msgpack`. `stream_end`'s `role` ∈ -`send|both` (half-close vs full close). Non-OPEN stream frames may carry -an optional `signer` pubkey so a relaying station (not just the -originating daemon) can be authenticated per-hop. See §13 for the full -client-side usage pattern (caller and provider roles) on top of these -frames. - -**Correction, 2026-08-29 — this crate didn't actually stamp `signer` -until today, despite this section documenting it correctly all along.** -`src/frame.rs`'s `StreamDataSpec`/`StreamEndSpec`/`StreamErrorSpec` -never carried the field, and every real call site -(`StreamHandle::send_data`/`close_send`/`abort`) never supplied it — -found live: a cross-station stream (provider on -`station-de-frankfurt.macula.io`, caller on `station-it-milan.macula.io`) -opened correctly but silently never delivered a single DATA frame, -because `macula_station_peer_observer.erl`'s multi-hop verify falls back -to "the connection this frame arrived on" when `signer` is absent — fine -for the direct client→first-station edge, wrong at any station→station -hop after that. Fixed: the three specs gained `signer: Option<[u8; 32]>` -and every real call site now always supplies -`Some(identity.public_bytes())`. Confirmed live, both directions, after -the fix (`tests/live_station.rs`'s -`cross_station_streaming_round_trip_frankfurt_provider_milan_caller`). - -**Correction, 2026-08-28 — the `msgpack` encoding is not a second wire -codec.** An earlier draft of this section (quoted above in the original -form for the record) read `encoding`'s `msgpack` value as meaning -`stream_data`'s `body` is pre-serialized through a real MessagePack -codec, distinct from the frame envelope's own CBOR — implying a Rust -port would need an `rmp-serde` dependency. Verified directly against -`macula-io/macula` v10.10.0 and it's wrong: `msgpack` was **removed from -macula's own dependencies in v3.0.0** (`rebar.config`'s own comment: -"wire protocol switched to CBOR"); the one remaining `msgpack:pack` call -in the entire repo is in an unrelated legacy DHT test, never on the -`stream_data` path. Built a real `stream_data` frame with -`encoding = msgpack` and a structured Erlang map as `body` in a live -`rebar3 shell`, round-tripped it through `macula_frame:encode/1` + -`decode/1`, and got the map straight back — `body` is embedded as an -ordinary nested value in the frame's own canonical-CBOR envelope, same -as CALL's `payload`. `encoding` is purely a semantic hint for the -receiver; **no second codec, no `rmp-serde` dependency needed.** -Confirmed at the crate level too: -`stream_data_msgpack_frame_matches_the_reference_byte_for_byte` (Rust -crate `macula-rust`, `src/frame.rs`) matches the reference's -signature byte-for-byte with exactly this shape. - -### 6.11 Content transfer (`want`, `have`, `block`, `manifest_req`, -`manifest_res`, `cancel`) -Bitswap-style block exchange keyed by 34-byte MCID -(`<>`). - -**Correction, 2026-08-28: these frame types are very likely -station-to-station only, not something a mobile client needs at all.** -A full read of the client-side content-sharing stack -(`macula_content_transfer.erl`, `macula_upload.erl`, `macula_download.erl`, -`macula_pusher.erl`, `macula_manifest.erl`) found **zero references** to -`want`/`have`/`block`/`manifest_req`/`manifest_res`/`cancel` anywhere in -the client SDK. The client-facing content API uses ordinary `call`/ -`result` (§6.4) against well-known `_content.*` procedure names instead -— see §12. These frame types are plausible station-to-station DHT -replication/gossip primitives (matching `macula/CLAUDE.md`'s listing of -"content" as a Relay, not SDK, concern), not part of what a client speaks. -Not fully confirmed (would need to read macula-station's own source to -be certain), but strong enough evidence to deprioritize this section -entirely for a mobile client — see §12 for the actual mechanism to build. - -## 7. Stream model - -- **Control stream:** one bidirectional QUIC stream, opened by the client - right after the handshake starts, carries CONNECT/HELLO/GOODBYE and - every non-streaming application frame in both directions for the life - of the connection. -- **Dedicated streams:** opened per streaming-RPC session - (`stream_open`/…) or per content-transfer session. Either side can open - one; the receiving side has no advance notice of *why* — it reads the - new stream's own first frame to learn its purpose - (`macula_peering_conn.erl:565`, comment block explains a real - production race here: the peer must be notified of the new stream - *before* the NIF is told to start delivering data on it, or fast/local - peers can lose the opening bytes — worth replicating this ordering - exactly in a Rust client that also accepts inbound dedicated streams). - -## 8. Source-route header (binary, not CBOR) - -Used inside the opaque `source_route`/`source_route_reverse`/ -`source_route_partial` fields of `call`/`stream_open`/`result`/`error`. -Fully specified, fixed binary layout -(`macula_source_route.erl`, doc lines 1-37): - -``` -offset size field -0 1 version (currently 1) -1 1 total_hops (1..8) -2 1 current_hop -3 8 deadline (unsigned, big-endian, absolute Unix ms) -11 16 path_hash — first 16 bytes of SHA-256(concat(hops)) -27 16×N hops[0..N-1] — each the first 16 bytes of the hop's NodeId -``` - -Fixed overhead 27 bytes; max size (8 hops) 155 bytes. `path_hash` is -verified on every decode — a mismatch is a hard reject -(`path_hash_mismatch`), not a warning. For a mobile client making a -*direct* call to one known station (no multi-hop routing requested), -this field is simply empty/absent; it only needs implementing when the -client wants to request or interpret explicit multi-hop routing. - -## 9. BOLT#4 error taxonomy - -`macula_bolt4.erl`, 17 entries (0x00-0x10), adapted from Lightning -Network's onion-failure codes. Each `call_error` frame carries one of -these as `code`, plus the reporting station's own frame signature so a -downstream hop can't forge "not my fault." Full table (code, name, advisory -retry policy): - -| Code | Name | Retry policy | -|---|---|---| -| 0x00 | `ok` | none | -| 0x01 | `unknown_next_peer` | different_path | -| 0x02 | `temporary_relay_failure` | same_path_after_backoff | -| 0x03 | `relay_disabled` | different_path | -| 0x04 | `node_not_found_at_target_relay` | caller_recompute_with_lookup | -| 0x05 | `target_realm_refused` | application | -| 0x06 | `loop_detected` | caller_recompute | -| 0x07 | `expiry_too_soon` | caller_extends_deadline | -| 0x08 | `upstream_congestion` | exponential_backoff | -| 0x09 | `invalid_path_header` | caller_recompute | -| 0x0A | `crypto_puzzle_invalid` | crypto_drop | -| 0x0B | `realm_not_authoritative_here` | caller_recompute_with_lookup | -| 0x0C | `tombstoned` | application | -| 0x0D | `payload_too_large` | application | -| 0x0E | `signature_invalid` | crypto_drop | -| 0x0F | `unknown_error` | log_and_caution | -| 0x10 | `unauthorized` | application (missing/invalid UCAN on a gated procedure) | - -`none`, `application`, and `crypto_drop` are the three non-retryable -policies; everything else means "retry, differently." - -## 10. Rust crate reuse — what's already there vs. what needs writing - -Confirmed from the NIF `Cargo.toml`s in `native/*`, as observed at this -project's inception (2026-08-28) — this table describes `macula-io/macula`'s -own native NIFs, not this crate. `macula-rust`'s own `Cargo.toml` is the only -authoritative source for its current dependency versions, and has since -diverged from some of the rows below via its own dependency-refresh passes -(e.g. `ed25519-dalek` 3.0, `rcgen` 0.14 as a dev-only dependency here vs. -`native/macula_quic`'s still-current production `rcgen 0.13` pin) — nothing -wrong with that divergence (this crate's `rcgen` only ever generates -synthetic test certs, never anything the Erlang side parses or verifies), -just don't read this table as this crate's current manifest. - -| Concern | Existing Rust crate (already a macula dependency) | Reuse directly? | -|---|---|---| -| QUIC engine | `quinn` 0.11 | Yes | -| TLS | `rustls` 0.23 (`ring` backend) | Yes | -| Self-signed cert generation | `rcgen` 0.13 | Yes | -| Pubkey-pin verifier | (hand-written, ~150 lines in `cert.rs`) | Port near-verbatim | -| Ed25519 sign/verify | `ed25519-dalek` 2.1 | Yes | -| Puzzle grind/verify | (hand-written, `macula_crypto_nif::grind_puzzle`) | Trivial to reimplement: generate Ed25519 keypairs, SHA-256 the pubkey, check leading zero bits, repeat. No need to read the NIF source — the algorithm is fully specified from the Erlang side (§5). | -| Hashing | `blake3` 1.5 (where BLAKE3 is used; source-route hop-hash uses SHA-256 via Erlang's `crypto:hash/2`, a separate primitive — confirm which Rust crate covers that path, likely `sha2`) | Yes for BLAKE3 uses | -| CBOR (deterministic wire codec) | none — hand-rolled in `native/macula_cbor_nif/src/deterministic.rs` (410 lines) | Transcribe directly, algorithm fully known (§4). `ciborium` is a *different*, non-deterministic code path in the same NIF crate and is irrelevant to the wire format. | -| MRI parsing | (custom, `macula_mri_nif`) | Study before porting | -| DID/UCAN | `ed25519-dalek`-based (`macula_did_nif`/`macula_ucan_nif`) | Study before porting | - -What still needs writing in Rust, with no existing crate to lean on: the -frame envelope + atom-vocabulary table (§4, §6), the connection state -machine (§3), the source-route codec (§8, trivial — ~60 lines given the -fixed layout above), and the puzzle-evidence handshake field (unresolved, -§5). - -## 11. Open items before any Rust code gets written - -1. ~~Canonical CBOR verification.~~ **RESOLVED, 2026-08-28** — see §4. - `ciborium` was never the actual wire codec; the real algorithm is - hand-rolled, fully traced, and directly portable. -2. ~~`puzzle_evidence`.~~ **RESOLVED, 2026-08-28** — see §5's callout. - One-time keypair grinding at identity creation (sub-millisecond at - default difficulty), a trivial deterministic hash on every CONNECT - after that, and confirmed to apply to every dialer, edge clients - included — not a station-to-station-only concern. -3. **`macula_record:encode/1`.** Needed for DHT `store`/`replicate`/ - `value` frames (record payloads are encoded by a separate codec, not - `macula_frame`'s own `to_wire/1`). Not needed for CALL/PUBLISH-only v1. -4. ~~Iroh raw-dial capability.~~ **DECIDED, 2026-08-28 — not pursuing - Iroh.** Reassessed against what's now confirmed about macula's actual - architecture: edges are dial-out only (no NAT-traversal/relay gap for - Iroh to fill), and macula already owns its own discovery (Kademlia - DHT), gossip (Plumtree/HyParView), pubsub, and pubkey-pinned identity - — all things Iroh would otherwise bring, and all things that would - *compete* with macula's own stack rather than complement it if - adopted. The one real remaining candidate, QUIC connection migration - for WiFi↔cellular handover, is a baseline feature of QUIC itself - (RFC 9000 Connection IDs) that `quinn` — already a macula dependency — - should already support; any extra mobile-specific network-change - detection glue can be hand-written directly against `quinn` later if - real-world testing shows it's needed, without adopting Iroh's whole - addressing/discovery/gossip stack to get it. The one genuinely useful - thing Iroh demonstrated — shipping a Rust core to iOS/Android via - UniFFI — doesn't require Iroh either: UniFFI is a separate, - general-purpose Mozilla tool this crate can depend on directly. -5. **v1 scope decision — superseded, 2026-08-28.** The original cut - deferred streaming RPC and content transfer wholesale. Both have now - been fully traced (§12-§13) and turn out to be cheap additions, not - separate protocols: content sharing's core path reuses `call`/`result` - (§6.4) verbatim, and push-upload reuses streaming RPC (§6.10) - verbatim. Revised v1 cut: transport + handshake (§2-§5) + - `call`/`result`/`error` (§6.4) + `publish`/`subscribe`/`event` (§6.8) - + `advertise`/`unadvertise` (§6.9) + streaming RPC caller role (§13.1) - + content get/put, single-block and chunked (§12), still deferring - DHT, HyParView, Plumtree, and the streaming/content *provider* roles - (§13.2) as v2. See §12-§13 for what's now specced and what's still - open within them. - -## 12. Content sharing (upload/download) — client-side mechanism - -Traced in full from `macula_content_transfer.erl` (799 lines — the core), -`macula_manifest.erl` (268 — chunking/hashing, §4's "Rust crate reuse" -table already covers reuse), `macula_upload.erl` (299), `macula_pusher.erl` -(308), `macula_download.erl` (270). `macula_feeder.erl` (278, -`macula_content_transfer_registry.erl`, 81, and the trivial `*_sup.erl` -files) were not read in full — their role is inferable from what their -callers/siblings already show (see §12.3), and none of it is wire-level. - -**Headline finding: this is not a separate wire protocol.** Content -put/get is ordinary `call`/`result` (§6.4) against four well-known -procedure names, sent over a *dedicated* QUIC stream (§7) instead of the -control stream. Push-upload (§12.3) is ordinary streaming RPC (§6.10), -full stop. Nothing here needs new frame types — §6.11's `want`/`have`/ -`block` frames appear to be unused by the client entirely (see the -correction there). - -### 12.1 The four procedures - -All calls use realm `<<0:256>>` (32 zero bytes — a reserved sentinel for -content operations, distinct from any real realm), and run over a -dedicated stream opened via the same "dedicated stream" mechanism as -streaming RPC (§7), not the control stream. - -| Procedure | Payload (call) | Reply (result) | -|---|---|---| -| `_content.put_block` | `#{mcid, payload}` — `payload` is the raw chunk bytes | `ok` \| `hash_mismatch` | -| `_content.get_block` | `#{mcid}` | raw binary (the block) \| `not_found` | -| `_content.put_manifest` | `#{manifest}` — the full manifest map (§4's crate-reuse table, chunking algorithm) | `ok` | -| `_content.get_manifest` | `#{mcid}` | the manifest map \| `not_found` | - -**Implemented + live-verified 2026-08-28** (`src/manifest.rs`, `src/content.rs`, -Rust crate `macula-rust`). Two things worth recording that weren't obvious -from reading the Erlang alone: - -- **`name`'s wire representation depends on which computation you're in.** - `macula_manifest`'s canonical MCID hash input wraps `name` as CBOR *text* - (`compute_mcid`'s own narrow special case), but the manifest map as actually - sent in a `_content.put_manifest` call payload (`to_wire`) encodes `name` as - a raw *byte string* — its real `binary()` type. Confirmed by encoding a real - manifest through the general deterministic-CBOR codec and inspecting the - bytes, not inferred from the type spec (the CALL `procedure`/PUBLISH `topic` - lesson elsewhere in this doc was exactly this kind of inference trap). -- **v1 client implementation is deliberately sequential, not multi-lane.** The - Rust crate opens exactly one dedicated stream per `put`/`get` call and runs - every `_content.*` call on it in order — no round-robin lanes. This is a - documented simplification, not a wire deviation: every call, the MCID - scheme, and the manifest format are identical either way, so a sequential - client interoperates fully with a station built to serve a parallel-lane - peer. Multi-lane parallelism is purely a throughput optimization, addable - later with zero wire change. Confirmed live: both a 4096-byte single-block - round trip and a ~536KB (3-chunk) round trip succeeded first try against - `station-de-frankfurt.macula.io`, including a `not_found` probe against a - made-up MCID. - -**MCID for a single block** is computed client-side before the call: -`<<1, 0x55, blake3(bytes)>>` (`macula_content_transfer.erl:put_single_block/3`). -**Always re-verify a fetched block's hash client-side against its MCID**, -even though the station verified it at put time — you may be fetching -from a station that only relayed it, not the one that stored it, so its -answer isn't inherently trustworthy. `verify_block_hash/2` is the -reference: recompute `blake3(bytes)`, compare to the MCID's embedded -hash. - -### 12.2 Single-block vs. chunked - -Determined without any network round trip: -- **Put:** chunked iff `byte_size(Bytes) > 262144` (256 KiB, `macula_manifest:default_chunk_size/0`). -- **Get:** chunked iff the MCID's codec byte is `0x56` (`CODEC_MANIFEST`); single-block iff `0x55` (`CODEC_RAW`). - -**Single-block** is one dedicated stream, one CALL/RESULT round trip. -Nothing more to it. - -**Chunked** runs a "multi-stream lanes" algorithm -(`macula_content_transfer.erl` lines 539-765): -- **Put:** the manifest is computed entirely locally - (`macula_manifest:create/1`, pure, no network) — chunks and their MCIDs - are all known upfront. Chunks are distributed round-robin - (`index rem stream_count`) across up to `stream_count` dedicated - streams (default 4, capped at the actual chunk count — a 2-chunk - transfer never opens more than 2). Each stream ("lane") runs its own - independent sequential queue: one `_content.put_block` in flight at a - time per lane, next chunk starts only once the current one's - CALL/RESULT completes. Once every lane's queue is empty, fire one - final `_content.put_manifest` on the primal stream (the first stream - opened) to register it. -- **Get:** the manifest is unknown upfront, so it's fetched first — one - `_content.get_manifest` call on the single stream the initial connect - opened. Once it's back (with `chunk_count`), lanes are set up the same - way, this time distributing chunk *indices* rather than bytes. Each - lane sequentially `_content.get_block`s its assigned indices, - accumulating results into a map keyed by index (lanes finish in - whatever order their own network calls complete, not necessarily - index order). Once every lane is done, reassemble bytes in index order - and verify against the manifest's root hash via `macula_manifest:verify/2` - — this final step is pure, no network. -- **Extra streams are cheap to open** (a local QUIC operation on an - already-live connection — allocate a stream id, no peer round trip) - and opening one is allowed to fail without failing the transfer: it - just degrades to fewer lanes. -- **Retry:** each `_content.*` call is retried up to 3 times (200 ms - backoff) if the BOLT#4 error code it failed with is itself flagged - retryable (§9's table) — directly reuses the taxonomy already specced, - no separate retry policy to invent. -- **Cancel is QUIC-level, not application-level:** resets every - currently-open lane stream via QUIC `RESET_STREAM` - (`macula_station_link:abort_content_stream/4`), **not** a - `stream_error` application frame — a genuinely different abort - mechanism from general streaming RPC (§13), because a content-transfer - stream is a raw dedicated QUIC stream, not a `macula_stream`-managed - one. Don't conflate the two when porting. -- **Pause/resume** (chunked only): gates whether a lane starts its *next* - queued item; whatever's already in flight always finishes; resume - continues each lane from wherever it left off. A reasonable thing to - skip for a first mobile port — v1 can always run to completion or - cancel outright. - -### 12.3 Two ways content moves: pull vs. push - -**Pull (`macula_download`/`macula_feeder`):** fetch by an already-known -MCID, or announce content into the mesh for others to discover and pull -later (`macula_feeder`, not read in full — inferred role from -`macula_download`'s module doc: a provider's station auto-publishes a -signed `content_announcement` DHT record on receipt, so there's nothing -to explicitly advertise on the feeder side, unlike RPC procedures). -Trust model is deliberately lighter than RPC's direct-dial path — content -is self-verifying by hash, so `macula_download`'s direct-dial fetch can -use `pin_tls_cert => false, verify => none` for the QUIC dial itself and -still be safe, because §12.1's client-side hash re-verification is what -actually protects the caller, not the station's identity. - -**Push (`macula_pusher`/`macula_upload`):** actively sends bytes AT a -specific, already-known recipient advertising an upload procedure — -**and this path uses zero content-transfer machinery at all.** It's -`client_stream`-mode streaming RPC (§6.10/§13), full stop: the manifest -(`macula_manifest:create/2`) rides as `stream_open`'s `args`, each chunk -is one `stream_data` frame sent in order over the ONE stream (no -multi-lane parallelism — that's explicitly a content-transfer-only -mechanism, per `macula_pusher.erl`'s own doc comment correcting an -earlier draft of the plan that claimed otherwise), `close_send` half-closes, -and the terminal `stream_reply` (§6.10) carries the receiver's verified -`{ok, Mcid}` or `{error, Reason}` — the receiver (`macula_upload`) -reassembles and verifies against the manifest before ever setting that -reply, so a caller blocking on it knows the bytes actually arrived -intact, not merely that local `send/2,3` calls returned `ok`. - -**For a mobile client:** push is the better fit for "upload a photo to a -known destination" (simpler, reuses §13's already-specced streaming -primitive, no new mechanism). Pull is the better fit for "fetch a piece -of content by its content-address" (§12.1-§12.2). Both are worth having; -neither requires touching §6.11's frames. - -## 13. General-purpose streaming RPC — client-side mechanism - -Traced in full from `macula_stream.erl` (581 lines — the per-stream wire -state machine), `macula_streamer.erl` (452 — provider/server role), -`macula_stream_sink.erl` (253 — caller/consumer role). Not read: -`macula_stream_local.erl` (195, an in-process test-only carrier, not -wire-relevant) and `macula_streamer_sup.erl` (33, trivial supervisor -boilerplate). - -### 13.1 Caller (consumer) role — the one a mobile client mostly wants - -Pattern, from `macula_stream_sink.erl`: - -1. `call_stream(Pool, Realm, Procedure, Args, Opts)` sends `stream_open` - (§6.10) and returns a stream handle once opened. `Opts` selects - `mode` (`server_stream` for "the provider pushes chunks at me," - `client_stream` for "I push chunks at the provider" — see §12.3's push - path for that mode in practice, `bidi` for both directions). -2. Drive a receive loop: `recv/2` blocks for the next `stream_data` - frame, decoded per its own `encoding` field (`raw` → bytes, `msgpack` - → a structured value — **not** a second wire codec, see §6.10/§13.3's - correction: `body` is an ordinary nested value in the frame's own - CBOR envelope either way). Loop until `eof` (peer sent `stream_end`) - or `{error, Reason}` (peer sent `stream_error`, or the underlying - connection died). -3. For `client_stream`/`bidi` modes wanting a result: `send/2,3` each - chunk in order (`stream_data`), `close_send/1` when done - (`stream_end` with `role => send`), then `await_reply/1,2` blocks for - the provider's terminal `stream_reply`. -4. **Non-normal termination must send an explicit abort, not just drop - the connection.** `macula_stream_sink.erl`'s own rule: a clean stop - (eof reached, or the consumer's own callback choosing to stop - cleanly) closes both sides normally; anything else (a `recv` error, a - crash, a non-normal stop) sends the peer an explicit `stream_error` - abort — so the other side learns this was a cancellation/failure - rather than mistaking a dropped connection for a clean end-of-stream. - Worth replicating exactly: the distinction is the only signal the - peer gets. - -### 13.2 Provider (server) role — BUILT + LIVE-VERIFIED 2026-08-28 - -Pattern, from `macula_streamer.erl` and `macula_station_link.erl` -(`handle_inbound_stream_open`, `dispatch_dedicated_frame`, -`macula_peering_conn.erl`'s inbound-`new_dedicated_stream` handoff): -`advertise_stream/5` registers a handler invoked per inbound -`stream_open`; the module drives `recv/2` on the provider's own stream -for `client_stream`-mode procedures (mirroring §13.1's loop, just on the -other end) and exposes `send/2,3`/`close/1` for `server_stream`-mode -ones to push with. Same non-normal-termination → explicit abort rule as -§13.1, symmetric. **Wire mechanics, confirmed from source before any -Rust was written:** an inbound `stream_open` for an advertised procedure -arrives as the first frame on a *fresh dedicated QUIC stream the station -opens toward the advertiser* — the advertiser has no other notice it's -coming; ADVERTISE itself (§6.9) flows on the shared control stream and -is the SAME wire frame whether registering for unary CALL routing or -streaming. - -**Rust port (`src/frame.rs`'s `parse_stream_open`, `src/connection.rs`'s -`Session::advertise`/`accept_dedicated_stream`, `src/stream.rs`'s -`StreamHandle::accept`/`send_reply`) built and live-verified same day** -against `station-de-frankfurt.macula.io`: two independent connections, -one advertises and accepts an inbound stream, the other dials in and -pushes/pulls data — the station really does open a fresh dedicated -stream toward the advertiser and route the caller's `stream_open` onto -it, exactly as the Erlang source says. First time this crate has been on -the *receiving* end of a mesh interaction it didn't initiate. - -The Erlang reference's own inbound-stream handoff has a documented race -(`macula_peering_conn.erl`'s "notify before enabling active mode" — a -fast/local peer's first bytes can arrive before the owning Erlang process -even knows the stream exists, because the `quicer` NIF's stream -resources start passive). **Confirmed this doesn't apply to the Rust -port:** `quinn`/QUIC buffers inbound stream data at the transport layer -regardless of when the application calls `accept_bi()`/starts reading, -so there's no analogous "arm before read" step needed here — the race is -specific to macula-station's own NIF architecture, not a general QUIC -property. - -### 13.3 Wire-level notes that apply to both roles - -- One dedicated QUIC stream per streaming-RPC session (§7), never the - control stream. -- `stream_data`'s `encoding` field: `raw` (bytes as-is) or `msgpack` - (a structured value). **Corrected 2026-08-28, see §6.10 above: this is - NOT a second serialization format.** msgpack was removed from macula's - own dependencies in v3.0.0; `body` for `encoding = msgpack` is embedded - directly as an ordinary nested value in the frame's own deterministic - CBOR envelope (§4), verified by round-tripping a real frame through - `macula_frame:encode/1`/`decode/1`. No msgpack codec (`rmp-serde` or - otherwise) is needed in a Rust port — a plain `Value` covers both - `encoding` variants. -- Sequencing: `seq_out`/`seq_in` counters per direction, tracked - independently — not used for reordering (frames arrive in order on a - single QUIC stream by construction) but as a sanity/debugging signal. -- `handle_down`/owner-death semantics (`macula_stream.erl`) matter less - for a Rust port — that's Erlang-process-monitor plumbing with no wire - equivalent; the wire-relevant rule is just "stream owner gone ⇒ close - or abort the stream," which any reasonable async-Rust structured- - concurrency approach gets for free. - -### 13.4 Forward-compatibility note: live/unbounded streaming (2026-08-28) - -**Confirmed gap, out of scope here, but worth designing around.** A -concrete real-world case (`hecate-tube` / macula-portal's "Macula TV") was -checked directly: its ingest path is plain HTTP upload to a conventional -web server (mesh not involved at all — confirmed in -`maybe_upload_video_clip.erl`), and its *playback* path is `server_stream` -streaming RPC reading an **already-complete file** off local disk -(`stream_video_clip_by_id.erl`, whose own comment states "the mesh -Content primitive is never the video-bytes path"). Neither is "capture -device pushes a live, unbounded feed into the mesh as it happens." Nothing -in the ecosystem does that today, on either the client or the receiving -side. - -The wire primitive is not the gap: `client_stream`-mode `stream_open` with -no manifest, followed by `stream_data` frames pushed continuously with no -predetermined end, is already valid against everything in §13.1 — a -producer just never knows total length upfront and that's fine, nothing -in the frame format requires it. The gap is entirely a missing *receiving -service* (something implementing §13.2's provider role in `client_stream` -mode, doing something useful with each chunk as it arrives — re-publish -live, buffer into a rolling window, hand off to a segmenter) — that's SDK/ -station-side application design, explicitly out of scope for this repo and -not something to build now. - -**What this means for this crate's design, without building the receiving -side:** don't route a live/unbounded producer through the same API shape -as §12.3's bounded push-upload (which computes a manifest from the full -byte count upfront — structurally wrong for "still recording, unknown -duration"). Expose live `client_stream` publishing as its own API surface -— open, push chunks as captured, close when done — separate from the -manifest-based upload path, so the day a receiving procedure exists on the -SDK/station side, this crate points at a new procedure name with no -protocol-level rework. A seam, not a feature. - -## 14. UniFFI mobile bindings — crate architecture, started 2026-08-28 - -**Every application primitive wrapped, same day.** A separate crate, `macula-rust-ffi`, -depending on the core `macula-rust` crate via a path dependency — -structurally identical to `iroh-ffi`'s relationship to `iroh`, confirmed -by reading the live `n0-computer/iroh-ffi` repo directly rather than -assuming: same crate separation, same modern UniFFI proc-macro style -(`uniffi::setup_scaffolding!()`, `#[uniffi::export]`, `#[derive(uniffi:: -Object/Enum/Error)]`) instead of the older `.udl`-file approach, same -`tokio` async-runtime feature (native async support, no callback/blocking -rewrite needed since this crate is already tokio-based throughout), same -`crate-type = ["staticlib", "cdylib"]` plus a `uniffi-bindgen` binary -target for codegen. - -**Why a separate crate, not code inside the core one:** this is what -keeps `macula-rust` itself exactly as usable from plain Rust, a CLI, -or WASM as it was before — zero UniFFI dependency, zero FFI-shaped types, -in the core crate. The doc comment at the top of `src/lib.rs` -("Mobile... is the flagship consumer driving this work, not the ceiling -on it") is enforced structurally by this separation, not just stated. - -**What's exposed — every application primitive the core crate has:** -- `FfiKeyPair` — identity generation, `node_id()`. -- `FfiSession` — `connect` (CONNECT/HELLO), `call` (CALL/RESULT/ERROR), - `publish`/`subscribe`/`unsubscribe`/`recv_event` (§6.8), `content_put`/ - `content_get` (§12), `stream_open` (§13.1, returns an `FfiStream`), - `advertise`/`unadvertise` (§6.9), `accept_stream` (§13.2, blocks for - the next inbound STREAM_OPEN, returns an `FfiAcceptedStream`), `close`. -- `FfiStream` — `send_data`/`close_send`/`recv`/`await_reply`/`abort` - (caller role, §13.1) plus `send_reply` (provider role, §13.2) — the - same object serves either role, since a stream's wire vocabulary is - symmetric regardless of which side opened it (mirrors `StreamHandle` - exactly). -- `FfiValue` (0.2.0) — a mirror of `cbor::Value`: `Null`/`Int`/`Bytes`/ - `Text`/`Float`/`Items`/`Fields`, the last two recursing through `Vec` - for `cbor::Value`'s own `List`/`Map`. Named `Items`/`Fields` rather - than `List`/`Map`: UniFFI's Kotlin codegen emits an unqualified - `List`/`Map` field type for a `Vec`/dictionary-shaped variant, - which resolves to the sibling variant class of the same name inside - `FfiValue`'s own sealed class body, not `kotlin.collections.List` — - confirmed by compiling the generated bindings before the rename. - `Fields` uses a dedicated `FfiMapEntry{key, value}` record rather than - `HashMap`, since `cbor::Value::Map`'s own keys are - arbitrary values, not just text. `Int` is narrowed from `i128` to - `i64` (UniFFI has no 128-bit integer type; an out-of-range value - returns an explicit `FfiError::UnrepresentableValue` rather than - silently truncating). -- `FfiCallResponse`, `FfiEvent`, `FfiStreamItem`, `FfiStreamReply`, - `FfiStreamOpenInfo`, `FfiAcceptedStream` — mirror - `frame::CallResponse`/`frame::EventInfo`/`stream::StreamItem`/the - `(payload, responded_by)` pair `StreamHandle::await_reply` returns/ - `frame::StreamOpenInfo`/the `(StreamHandle, StreamOpenInfo)` pair - `StreamHandle::accept` returns. `FfiAcceptedStream` embeds an - `Arc` directly as a record field — confirmed UniFFI 0.32 - supports an Object handle inside a Record, generating correctly in - both languages (Kotlin's version even picks up `Disposable` - automatically). `publish`'s `seq`/`published_at_ms` stay - caller-supplied rather than tracked internally by `FfiSession` (unlike - streaming RPC's per-stream `seq_out` counter): PUBLISH's `seq` is a - per-publisher, per-topic gap-detection sequence, and a client - publishing to several topics has to own that bookkeeping itself. - -**`accept_stream` holds the session's lock for as long as it waits** — -no other `FfiSession` method can run concurrently during that wait. Not -an FFI-layer restriction: the core crate's own `Session` has the same -property, since its control stream is single-owner by construction. - -**Not wrapped, and won't be until the core crate has it:** unary-RPC -provider dispatch (accepting an inbound CALL on the control stream and -replying — the core crate doesn't implement that role either, only -streaming's provider side needed it so far) and pubkey-pinned trust -(`connect` always uses WebPki — the core crate's `Trust::Pinned` exists -but isn't surfaced here yet). - -**Verified past "it compiles":** built the release `cdylib` and actually -ran `uniffi-bindgen generate` for both Kotlin and Swift, then inspected -the *generated source* — not just the build exit code — for the expected -async surface: Kotlin's `suspend fun connect(...)`/`suspend fun call(...)` -(proper coroutine integration, `AutoCloseable` object handles), Swift's -`static func connect(...) async throws -> FfiSession`/`func call(...) -async throws -> FfiCallResponse` (`Sendable` conformance, `Data` for byte -arrays). CI gained a `ffi-bindings` job that rebuilds the `cdylib` and -regenerates both languages on every push, as a codegen smoke test — it -doesn't (and, without a macOS/Android runner, can't) compile the -generated Kotlin/Swift against the real platform SDKs; that's the next -gap once actual mobile app integration starts. - -**Also fixed while wiring this up:** the existing CI workflow's -`clippy`/`test`/`doc` jobs were missing `--workspace` — Cargo's default -behavior for a workspace root that is *also* a package member is to -operate on just that root package unless `--workspace` is passed -explicitly, so before this fix those three jobs were silently never -touching the new crate at all (only `fmt --all` already covered it, -since `--all` is fmt's own workspace flag, spelled differently from the -others for historical reasons). Caught by directly comparing `cargo test` -vs `cargo test --workspace`'s own `Running` output, not assumed. diff --git a/src/node_key/key_file.rs b/src/node_key/key_file.rs index 65c7fd6..692ff54 100644 --- a/src/node_key/key_file.rs +++ b/src/node_key/key_file.rs @@ -13,7 +13,7 @@ use aws_lc_rs::rsa::KeyPair as RsaKeyPair; use aws_lc_rs::signature::KeyPair as _; use macula_mldsa::{PrivateKey, Zeroizing, ML_DSA_87}; -use super::{der, verify, NodeKey, Purpose, RsaHalf}; +use super::{der, verify, KeyError, NodeKey, Purpose, RsaHalf}; use crate::keystore::{KeyStore, KeyStoreError}; use crate::profile::Profile; @@ -60,6 +60,8 @@ pub enum KeyFileError { RoundTripFailed, /// The key store could not save or load the key. KeyStore(KeyStoreError), + /// A new key could not be made. + Generate(KeyError), } impl fmt::Display for KeyFileError { @@ -87,6 +89,7 @@ impl fmt::Display for KeyFileError { } KeyFileError::RoundTripFailed => f.write_str("the key does not sign and verify"), KeyFileError::KeyStore(e) => write!(f, "key store: {e}"), + KeyFileError::Generate(e) => write!(f, "a new key: {e}"), } } } @@ -138,6 +141,22 @@ impl NodeKey { Ok(key) } + /// The identity key at `path` in `profile`, or, when nothing is there, a + /// new one with the admission puzzle solved, saved there first. Anything + /// at `path` that does not load as such a key is refused and left as it + /// is, never replaced. + pub fn load_or_create(path: &Path, profile: Profile) -> Result { + match std::fs::symlink_metadata(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let key = NodeKey::generate_identity(profile, super::PUZZLE_DIFFICULTY) + .map_err(KeyFileError::Generate)?; + key.save(path)?; + Ok(key) + } + _ => NodeKey::load(path, Purpose::Identity, profile), + } + } + /// Keeps the key in `store`, as the bytes of its key file: the platform /// secure store a mobile app keeps its key in (see `crate::keystore`). pub fn save_to_keystore(&self, store: &dyn KeyStore) -> Result<(), KeyFileError> { diff --git a/tests/identity_key_file.rs b/tests/identity_key_file.rs index d5281d0..4b0ac58 100644 --- a/tests/identity_key_file.rs +++ b/tests/identity_key_file.rs @@ -235,3 +235,40 @@ fn a_key_kept_in_a_key_store_loads_back_as_the_same_key_and_is_checked_as_a_file Err(KeyFileError::WrongProfile(Profile::PqHybrid)) )); } + +#[test] +fn load_or_create_makes_a_puzzle_solved_key_once_then_loads_it() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("keys/node.key"); + let made = NodeKey::load_or_create(&path, Profile::PqPure).unwrap(); + assert_eq!(made.purpose(), Purpose::Identity); + assert!(macula_rust::node_key::puzzle_solved( + &made.node_id().unwrap(), + macula_rust::node_key::PUZZLE_DIFFICULTY + )); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0, "owner-only"); + let again = NodeKey::load_or_create(&path, Profile::PqPure).unwrap(); + assert_eq!(again.node_id().unwrap(), made.node_id().unwrap()); +} + +#[test] +fn load_or_create_never_replaces_a_file_that_does_not_load() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("node.key"); + write_owner_only(&path, b"not a key file"); + assert!(matches!( + NodeKey::load_or_create(&path, Profile::PqPure), + Err(KeyFileError::BadKeyFile) + )); + assert_eq!(std::fs::read(&path).unwrap(), b"not a key file"); + // A key of the other profile is refused, not replaced, too. + let hybrid = dir.path().join("hybrid.key"); + NodeKey::load_or_create(&hybrid, Profile::PqHybrid).unwrap(); + let before = std::fs::read(&hybrid).unwrap(); + assert!(matches!( + NodeKey::load_or_create(&hybrid, Profile::PqPure), + Err(KeyFileError::WrongProfile(Profile::PqHybrid)) + )); + assert_eq!(std::fs::read(&hybrid).unwrap(), before); +}