Skip to main content

saorsa_gossip_rendezvous/
lib.rs

1#![warn(missing_docs)]
2
3//! Rendezvous Shards for global findability without DNS/DHT
4//!
5//! Implements SPEC2 §9 Rendezvous Shards for publisher discovery.
6//!
7//! ## Overview
8//!
9//! Rendezvous shards provide global findability without requiring:
10//! - DNS infrastructure
11//! - DHT (Distributed Hash Table)
12//! - Centralized directory services
13//!
14//! ## How it works
15//!
16//! 1. **Shard Space**: k=16 → 65,536 shards
17//! 2. **Shard Calculation**: `shard = BLAKE3("saorsa-rendezvous" || target_id) & 0xFFFF`
18//! 3. **Publishers**: Gossip Provider Summaries to target's shard
19//! 4. **Seekers**: Subscribe to relevant shards, fetch from top providers
20//!
21//! ## Example
22//!
23//! ```rust
24//! use saorsa_gossip_rendezvous::{calculate_shard, ProviderSummary, Capability};
25//! use saorsa_gossip_types::PeerId;
26//!
27//! # fn main() -> anyhow::Result<()> {
28//! // Calculate shard for a target
29//! let target_id = [1u8; 32];
30//! let shard = calculate_shard(&target_id);
31//! assert!(shard <= u16::MAX);
32//!
33//! // Create a provider summary
34//! let provider = PeerId::new([2u8; 32]);
35//! let summary = ProviderSummary::new(
36//!     target_id,
37//!     provider,
38//!     vec![Capability::Site],
39//!     3600_000, // 1 hour validity
40//! );
41//!
42//! assert!(summary.is_valid());
43//! # Ok(())
44//! # }
45//! ```
46
47use saorsa_gossip_types::PeerId;
48use serde::{Deserialize, Serialize};
49use std::time::{Duration, SystemTime};
50
51/// Shard space size: k=16 → 2^16 = 65,536 shards per SPEC2 §9
52pub const SHARD_BITS: u32 = 16;
53/// Total number of shards: 2^16 = 65,536
54pub const SHARD_COUNT: u32 = 1 << SHARD_BITS; // 65,536
55/// Bitmask for shard calculation: 0xFFFF
56pub const SHARD_MASK: u32 = SHARD_COUNT - 1; // 0xFFFF
57
58/// Rendezvous prefix for shard calculation per SPEC2 §9
59const RENDEZVOUS_PREFIX: &[u8] = b"saorsa-rendezvous";
60
61/// Shard ID (0..65,535)
62pub type ShardId = u16;
63
64fn current_time_millis() -> u64 {
65    match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
66        Ok(duration) => duration.as_millis() as u64,
67        Err(_) => Duration::ZERO.as_millis() as u64,
68    }
69}
70
71/// Calculate the rendezvous shard for a target ID per SPEC2 §9
72///
73/// Formula: `shard = BLAKE3("saorsa-rendezvous" || target_id) & 0xFFFF`
74///
75/// # Arguments
76/// * `target_id` - The target identifier (32 bytes)
77///
78/// # Returns
79/// Shard ID in range [0, 65535]
80///
81/// # Example
82/// ```
83/// use saorsa_gossip_rendezvous::calculate_shard;
84///
85/// let target = [42u8; 32];
86/// let shard = calculate_shard(&target);
87/// // shard is u16, always in range [0, 65535]
88/// assert!(shard <= u16::MAX);
89/// ```
90pub fn calculate_shard(target_id: &[u8; 32]) -> ShardId {
91    let mut hasher = blake3::Hasher::new();
92    hasher.update(RENDEZVOUS_PREFIX);
93    hasher.update(target_id);
94    let hash = hasher.finalize();
95
96    // Take first 4 bytes and mask to 16 bits
97    let bytes = hash.as_bytes();
98    let value = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
99    (value & SHARD_MASK) as u16
100}
101
102/// Capability that a provider can serve per SPEC2 §9
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "UPPERCASE")]
105pub enum Capability {
106    /// Serves a Saorsa Site
107    Site,
108    /// Serves identity/presence information
109    Identity,
110}
111
112/// Provider Summary per SPEC2 §9
113///
114/// Wire format (CBOR):
115/// ```json
116/// {
117///   "v": 1,
118///   "target": [u8; 32],
119///   "provider": PeerId,
120///   "cap": ["SITE", "IDENTITY"],
121///   "have_root": bool,
122///   "manifest_ver": u64,
123///   "summary": { "bloom": bytes, "iblt": bytes },
124///   "exp": u64,
125///   "sig": [u8]
126/// }
127/// ```
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct ProviderSummary {
130    /// Protocol version (currently 1)
131    pub v: u8,
132    /// Target identifier (what is being provided)
133    pub target: [u8; 32],
134    /// Provider peer ID
135    pub provider: PeerId,
136    /// Capabilities this provider offers
137    pub cap: Vec<Capability>,
138    /// Whether provider has the root manifest
139    pub have_root: bool,
140    /// Manifest version (if applicable)
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub manifest_ver: Option<u64>,
143    /// Summary data (bloom filters, IBLT)
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub summary: Option<SummaryData>,
146    /// Expiration timestamp (unix ms)
147    pub exp: u64,
148    /// Arbitrary caller-defined extension data.
149    ///
150    /// This field is included in the signed payload when present.
151    /// When `None`, it is omitted from the wire format via `skip_serializing_if`,
152    /// so existing signatures over records without `extensions` remain valid.
153    ///
154    /// x0x uses this field to embed serialized `Vec<SocketAddr>` (bincode format)
155    /// so that agent reachability addresses are available from the rendezvous record.
156    #[serde(
157        default,
158        skip_serializing_if = "Option::is_none",
159        with = "optional_serde_bytes"
160    )]
161    pub extensions: Option<Vec<u8>>,
162    /// ML-DSA signature over all fields except sig
163    #[serde(with = "serde_bytes")]
164    pub sig: Vec<u8>,
165}
166
167/// Summary data for content reconciliation per SPEC2 §9
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct SummaryData {
170    /// Bloom filter bytes (optional)
171    #[serde(skip_serializing_if = "Option::is_none", with = "optional_serde_bytes")]
172    pub bloom: Option<Vec<u8>>,
173    /// IBLT bytes (optional)
174    #[serde(skip_serializing_if = "Option::is_none", with = "optional_serde_bytes")]
175    pub iblt: Option<Vec<u8>>,
176}
177
178mod serde_bytes {
179    use serde::{de::Visitor, Deserializer, Serializer};
180
181    pub fn serialize<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
182    where
183        S: Serializer,
184    {
185        serializer.serialize_bytes(bytes)
186    }
187
188    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
189    where
190        D: Deserializer<'de>,
191    {
192        struct BytesVisitor;
193
194        impl<'de> Visitor<'de> for BytesVisitor {
195            type Value = Vec<u8>;
196
197            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
198                formatter.write_str("a byte array")
199            }
200
201            fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
202            where
203                E: serde::de::Error,
204            {
205                Ok(v.to_vec())
206            }
207
208            fn visit_byte_buf<E>(self, v: Vec<u8>) -> Result<Self::Value, E>
209            where
210                E: serde::de::Error,
211            {
212                Ok(v)
213            }
214        }
215
216        deserializer.deserialize_bytes(BytesVisitor)
217    }
218}
219
220mod optional_serde_bytes {
221    use serde::{Deserializer, Serializer};
222
223    pub fn serialize<S>(bytes: &Option<Vec<u8>>, serializer: S) -> Result<S::Ok, S::Error>
224    where
225        S: Serializer,
226    {
227        match bytes {
228            Some(b) => serializer.serialize_bytes(b),
229            None => serializer.serialize_none(),
230        }
231    }
232
233    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error>
234    where
235        D: Deserializer<'de>,
236    {
237        use serde::de::Visitor;
238
239        struct OptionalBytesVisitor;
240
241        impl<'de> Visitor<'de> for OptionalBytesVisitor {
242            type Value = Option<Vec<u8>>;
243
244            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
245                formatter.write_str("an optional byte array")
246            }
247
248            fn visit_none<E>(self) -> Result<Self::Value, E>
249            where
250                E: serde::de::Error,
251            {
252                Ok(None)
253            }
254
255            fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
256            where
257                D: Deserializer<'de>,
258            {
259                struct BytesVisitorWrapper;
260                impl<'de> Visitor<'de> for BytesVisitorWrapper {
261                    type Value = Vec<u8>;
262
263                    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
264                        formatter.write_str("a byte array")
265                    }
266
267                    fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
268                    where
269                        E: serde::de::Error,
270                    {
271                        Ok(v.to_vec())
272                    }
273
274                    fn visit_byte_buf<E>(self, v: Vec<u8>) -> Result<Self::Value, E>
275                    where
276                        E: serde::de::Error,
277                    {
278                        Ok(v)
279                    }
280                }
281
282                deserializer
283                    .deserialize_bytes(BytesVisitorWrapper)
284                    .map(Some)
285            }
286        }
287
288        deserializer.deserialize_option(OptionalBytesVisitor)
289    }
290}
291
292impl ProviderSummary {
293    /// Create a new provider summary (unsigned)
294    ///
295    /// # Arguments
296    /// * `target` - Target identifier being provided
297    /// * `provider` - Provider peer ID
298    /// * `capabilities` - What this provider offers
299    /// * `validity_ms` - How long this summary is valid (milliseconds)
300    pub fn new(
301        target: [u8; 32],
302        provider: PeerId,
303        capabilities: Vec<Capability>,
304        validity_ms: u64,
305    ) -> Self {
306        let now = current_time_millis();
307
308        Self {
309            v: 1,
310            target,
311            provider,
312            cap: capabilities,
313            have_root: false,
314            manifest_ver: None,
315            summary: None,
316            exp: now + validity_ms,
317            extensions: None,
318            sig: Vec::new(),
319        }
320    }
321
322    /// Set whether provider has root manifest
323    pub fn with_root(mut self, has_root: bool) -> Self {
324        self.have_root = has_root;
325        self
326    }
327
328    /// Set manifest version
329    pub fn with_manifest_version(mut self, version: u64) -> Self {
330        self.manifest_ver = Some(version);
331        self
332    }
333
334    /// Set summary data
335    pub fn with_summary(mut self, summary: SummaryData) -> Self {
336        self.summary = Some(summary);
337        self
338    }
339
340    /// Set caller-defined extension data.
341    ///
342    /// The extension bytes are included in the signed payload.
343    /// x0x uses this field to encode agent socket addresses.
344    pub fn with_extensions(mut self, data: Vec<u8>) -> Self {
345        self.extensions = Some(data);
346        self
347    }
348
349    /// Check if summary is currently valid (not expired)
350    pub fn is_valid(&self) -> bool {
351        let now = current_time_millis();
352
353        now <= self.exp
354    }
355
356    /// Sign the summary with ML-DSA
357    ///
358    /// Signs all fields except `sig` using ML-DSA-65 from saorsa-pqc.
359    /// Uses CBOR serialization for deterministic signing.
360    pub fn sign(&mut self, signing_key: &saorsa_pqc::MlDsaSecretKey) -> anyhow::Result<()> {
361        use saorsa_pqc::{MlDsa65, MlDsaOperations};
362
363        // Serialize all fields except signature using CBOR
364        let mut to_sign = Vec::new();
365        ciborium::into_writer(
366            &SignableFields {
367                v: self.v,
368                target: &self.target,
369                provider: &self.provider,
370                cap: &self.cap,
371                have_root: self.have_root,
372                manifest_ver: self.manifest_ver,
373                summary: &self.summary,
374                exp: self.exp,
375                extensions: &self.extensions,
376            },
377            &mut to_sign,
378        )?;
379
380        // Sign with ML-DSA-65
381        let signer = MlDsa65::new();
382        let signature = signer.sign(signing_key, &to_sign)?;
383
384        self.sig = signature.as_bytes().to_vec();
385        Ok(())
386    }
387
388    /// Verify the summary signature
389    ///
390    /// Verifies the ML-DSA-65 signature over all fields except `sig`.
391    /// Uses CBOR serialization for deterministic verification.
392    pub fn verify(&self, public_key: &saorsa_pqc::MlDsaPublicKey) -> anyhow::Result<bool> {
393        use saorsa_pqc::{MlDsa65, MlDsaOperations, MlDsaSignature};
394
395        // Reconstruct signed data using CBOR
396        let mut to_verify = Vec::new();
397        ciborium::into_writer(
398            &SignableFields {
399                v: self.v,
400                target: &self.target,
401                provider: &self.provider,
402                cap: &self.cap,
403                have_root: self.have_root,
404                manifest_ver: self.manifest_ver,
405                summary: &self.summary,
406                exp: self.exp,
407                extensions: &self.extensions,
408            },
409            &mut to_verify,
410        )?;
411
412        // Verify signature
413        let verifier = MlDsa65::new();
414        let sig = MlDsaSignature::from_bytes(&self.sig)?;
415
416        Ok(verifier.verify(public_key, &to_verify, &sig)?)
417    }
418
419    /// Sign the summary using raw ML-DSA-65 secret key bytes.
420    ///
421    /// Equivalent to [`Self::sign`] but accepts raw bytes instead of a typed key.
422    /// Allows callers using different key wrappers (e.g., `ant-quic`) to sign
423    /// without depending on `saorsa-pqc` directly.
424    ///
425    /// # Errors
426    ///
427    /// Returns an error if the key bytes are not a valid ML-DSA-65 secret key
428    /// or if signing fails.
429    pub fn sign_raw(&mut self, secret_key_bytes: &[u8]) -> anyhow::Result<()> {
430        let key = saorsa_pqc::MlDsaSecretKey::from_bytes(secret_key_bytes)
431            .map_err(|e| anyhow::anyhow!("invalid secret key bytes: {:?}", e))?;
432        self.sign(&key)
433    }
434
435    /// Verify the summary signature using raw ML-DSA-65 public key bytes.
436    ///
437    /// Equivalent to [`Self::verify`] but accepts raw bytes instead of a typed key.
438    ///
439    /// # Errors
440    ///
441    /// Returns an error if the key bytes are invalid or signature verification fails.
442    pub fn verify_raw(&self, public_key_bytes: &[u8]) -> anyhow::Result<bool> {
443        let key = saorsa_pqc::MlDsaPublicKey::from_bytes(public_key_bytes)
444            .map_err(|e| anyhow::anyhow!("invalid public key bytes: {:?}", e))?;
445        self.verify(&key)
446    }
447
448    /// Serialize to CBOR wire format (RFC 8949)
449    pub fn to_cbor(&self) -> anyhow::Result<Vec<u8>> {
450        let mut buffer = Vec::new();
451        ciborium::into_writer(self, &mut buffer)?;
452        Ok(buffer)
453    }
454
455    /// Deserialize from CBOR wire format (RFC 8949)
456    pub fn from_cbor(data: &[u8]) -> anyhow::Result<Self> {
457        let summary = ciborium::from_reader(data)?;
458        Ok(summary)
459    }
460
461    /// Convert to bytes for transport
462    pub fn to_bytes(&self) -> anyhow::Result<bytes::Bytes> {
463        Ok(bytes::Bytes::from(self.to_cbor()?))
464    }
465
466    /// Parse from bytes received over transport
467    pub fn from_bytes(data: &[u8]) -> anyhow::Result<Self> {
468        Self::from_cbor(data)
469    }
470
471    /// Calculate the shard this summary should be gossiped to
472    pub fn shard(&self) -> ShardId {
473        calculate_shard(&self.target)
474    }
475}
476
477/// Helper struct for serializing fields to sign
478#[derive(Serialize)]
479struct SignableFields<'a> {
480    v: u8,
481    target: &'a [u8; 32],
482    provider: &'a PeerId,
483    cap: &'a Vec<Capability>,
484    have_root: bool,
485    manifest_ver: Option<u64>,
486    summary: &'a Option<SummaryData>,
487    exp: u64,
488    #[serde(skip_serializing_if = "Option::is_none", with = "optional_serde_bytes")]
489    extensions: &'a Option<Vec<u8>>,
490}
491
492#[cfg(test)]
493#[allow(clippy::expect_used, clippy::unwrap_used)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn test_shard_calculation_deterministic() {
499        let target = [42u8; 32];
500
501        let shard1 = calculate_shard(&target);
502        let shard2 = calculate_shard(&target);
503
504        assert_eq!(shard1, shard2, "Shard calculation must be deterministic");
505        // shard is u16, so it's always < 65536 by type
506    }
507
508    #[test]
509    fn test_shard_calculation_different_targets() {
510        let target1 = [1u8; 32];
511        let target2 = [2u8; 32];
512
513        let shard1 = calculate_shard(&target1);
514        let shard2 = calculate_shard(&target2);
515
516        // Different targets should (very likely) map to different shards
517        // This is probabilistic, but with 65k shards collision is unlikely
518        assert_ne!(
519            shard1, shard2,
520            "Different targets should map to different shards"
521        );
522    }
523
524    #[test]
525    fn test_shard_within_bounds() {
526        // Test with various inputs
527        for i in 0..100 {
528            let target = [i; 32];
529            let _shard = calculate_shard(&target);
530            // shard is u16, so it's always in valid range [0, 65535]
531        }
532    }
533
534    #[test]
535    fn test_provider_summary_creation() {
536        let target = [1u8; 32];
537        let provider = PeerId::new([2u8; 32]);
538
539        let summary = ProviderSummary::new(target, provider, vec![Capability::Site], 3_600_000);
540
541        assert_eq!(summary.v, 1);
542        assert_eq!(summary.target, target);
543        assert_eq!(summary.provider, provider);
544        assert_eq!(summary.cap.len(), 1);
545        assert!(summary.is_valid());
546    }
547
548    #[test]
549    fn test_provider_summary_expiry() {
550        let target = [2u8; 32];
551        let provider = PeerId::new([3u8; 32]);
552
553        // Create summary with very short validity
554        let summary = ProviderSummary::new(
555            target,
556            provider,
557            vec![Capability::Identity],
558            1, // 1ms validity
559        );
560
561        assert!(summary.is_valid(), "Should be valid immediately");
562
563        // Sleep to let it expire
564        std::thread::sleep(std::time::Duration::from_millis(5));
565
566        assert!(!summary.is_valid(), "Should be expired after sleep");
567    }
568
569    #[test]
570    fn test_provider_summary_builder() {
571        let target = [3u8; 32];
572        let provider = PeerId::new([4u8; 32]);
573
574        let summary = ProviderSummary::new(
575            target,
576            provider,
577            vec![Capability::Site, Capability::Identity],
578            60_000,
579        )
580        .with_root(true)
581        .with_manifest_version(42);
582
583        assert!(summary.have_root);
584        assert_eq!(summary.manifest_ver, Some(42));
585    }
586
587    #[test]
588    fn test_provider_summary_shard() {
589        let target = [5u8; 32];
590        let provider = PeerId::new([6u8; 32]);
591
592        let summary = ProviderSummary::new(target, provider, vec![Capability::Site], 3_600_000);
593
594        let shard = summary.shard();
595        assert_eq!(shard, calculate_shard(&target));
596    }
597
598    #[test]
599    fn test_cbor_round_trip() {
600        let target = [7u8; 32];
601        let provider = PeerId::new([8u8; 32]);
602
603        let summary =
604            ProviderSummary::new(target, provider, vec![Capability::Site], 60_000).with_root(true);
605
606        let cbor = summary.to_cbor().expect("CBOR serialization");
607        let decoded = ProviderSummary::from_cbor(&cbor).expect("CBOR deserialization");
608
609        assert_eq!(decoded.v, summary.v);
610        assert_eq!(decoded.target, summary.target);
611        assert_eq!(decoded.provider, summary.provider);
612        assert_eq!(decoded.have_root, summary.have_root);
613    }
614
615    #[test]
616    fn test_sign_and_verify() {
617        use saorsa_pqc::{MlDsa65, MlDsaOperations};
618
619        let target = [9u8; 32];
620        let provider = PeerId::new([10u8; 32]);
621
622        let mut summary =
623            ProviderSummary::new(target, provider, vec![Capability::Identity], 60_000);
624
625        // Generate keypair
626        let signer = MlDsa65::new();
627        let (pk, sk) = signer.generate_keypair().expect("keypair");
628
629        // Sign
630        summary.sign(&sk).expect("signing");
631        assert!(!summary.sig.is_empty(), "Signature should be populated");
632
633        // Verify
634        let valid = summary.verify(&pk).expect("verification");
635        assert!(valid, "Signature should be valid");
636
637        // Tamper
638        summary.have_root = true;
639
640        // Verify should fail
641        let valid = summary.verify(&pk).expect("verification");
642        assert!(!valid, "Tampered signature should be invalid");
643    }
644
645    #[test]
646    fn test_capability_serialization() {
647        let caps = vec![Capability::Site, Capability::Identity];
648
649        let mut buffer = Vec::new();
650        ciborium::into_writer(&caps, &mut buffer).expect("serialize");
651
652        let decoded: Vec<Capability> = ciborium::from_reader(&buffer[..]).expect("deserialize");
653
654        assert_eq!(decoded, caps);
655    }
656
657    #[test]
658    fn test_summary_data() {
659        let data = SummaryData {
660            bloom: Some(vec![1, 2, 3]),
661            iblt: Some(vec![4, 5, 6]),
662        };
663
664        let mut buffer = Vec::new();
665        ciborium::into_writer(&data, &mut buffer).expect("serialize");
666
667        let decoded: SummaryData = ciborium::from_reader(&buffer[..]).expect("deserialize");
668
669        assert_eq!(decoded.bloom, data.bloom);
670        assert_eq!(decoded.iblt, data.iblt);
671    }
672}