Skip to main content

hashiverse_lib/protocol/posting/
encoded_post_bundle.rs

1//! # `EncodedPostBundleV1` — the server-signed index of posts at a location
2//!
3//! A "post bundle" is a server's durable record of every post accepted into a given
4//! `(location_id, time bucket)` pair. This is the unit of replication, caching, and
5//! healing on the network — timelines fetch bundles, caches store bundles, and the
6//! two-phase heal protocol reconciles divergent bundles.
7//!
8//! The encoding is two parts glued together:
9//!
10//! - [`EncodedPostBundleHeaderV1`] — JSON-encoded, server-signed, enumerating the
11//!   `post_id`s and per-post body lengths the bundle contains, plus
12//!   `sealed`/`overflowed` flags, any `healed_ids`, and the publishing
13//!   [`crate::protocol::peer::Peer`] record. This is the bit that proves "server X
14//!   attests that these posts lived here at time T".
15//! - A concatenation of each [`crate::protocol::posting::encoded_post::EncodedPostV1`]
16//!   body, in the same order as the header's post id list.
17//!
18//! `verify()` is exhaustive: it checks the header signature, confirms the sum of
19//! header-declared lengths exactly spans the body, and decrypts each post to verify
20//! its own signature and that its plaintext `post_id` matches the header's claim.
21
22use std::collections::HashSet;
23use crate::{anyhow_assert_eq, anyhow_assert_ge};
24use crate::protocol::peer::Peer;
25use crate::protocol::posting::encoded_post::EncodedPostV1;
26use crate::tools::buckets::BucketLocation;
27use crate::tools::time::TimeMillis;
28use crate::tools::types::{Hash, Id, ID_BYTES, Signature, SignatureKey, VerificationKey};
29use crate::tools::{config, hashing, json, signing};
30use bytes::{Buf, BufMut, Bytes, BytesMut};
31use serde::{Deserialize, Serialize};
32use std::fmt::{Debug, Display};
33
34/// The server-signed index for all posts accumulated under one (location, time-bucket) key.
35///
36/// Posts on hashiverse are not stored one-by-one on the DHT: they are grouped into "bundles"
37/// keyed by a [`crate::tools::buckets::BucketLocation`] (a location_id plus a time bucket).
38/// An `EncodedPostBundleHeaderV1` is the header that a server publishes describing the
39/// bundle's current state — the list of post IDs it holds, their lengths, which ones have
40/// been healed (re-uploaded after loss), whether the bundle has overflowed (too many posts,
41/// go more granular), whether it is sealed (old enough that new posts are unlikely), and the
42/// [`Peer`] record of the server publishing it.
43///
44/// The whole header is signed by the publishing peer, which lets any client reading the
45/// bundle verify both the contents list and the identity of the hosting server before
46/// deciding to fetch individual posts.
47// Contains all the posts for a given location_id in a particular bucket
48#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
49pub struct EncodedPostBundleHeaderV1 {
50    pub time_millis: TimeMillis, // When was this bundle last updated
51    pub location_id: Id,
52    pub overflowed: bool, // This bucket has overflowed, so go more granular
53    pub sealed: bool,    // Enough time has passed that it is unlikely that new posts will be added to this cluster
54    pub num_posts: u8,
55    pub encoded_post_ids: Vec<Id>, 
56    pub encoded_post_lengths: Vec<usize>,
57    pub encoded_post_healed: HashSet<Id>,
58    pub peer: Peer,
59
60    pub signature: Signature, // Peer.sign(get_hash_for_signing())
61}
62
63impl EncodedPostBundleHeaderV1 {
64    pub fn get_hash_for_signing(&self) -> anyhow::Result<Hash> {
65        let time_millis_be = self.time_millis.encode_be();
66        let overflowed_bytes = [self.overflowed as u8];
67        let sealed_bytes = [self.sealed as u8];
68        let num_posts_bytes = [self.num_posts];
69        let encoded_post_lengths_be: Vec<[u8; 8]> = self.encoded_post_lengths.iter().map(|&l| (l as u64).to_be_bytes()).collect();
70        let peer_hash = self.peer.signature_hash_generate()?;
71
72        let mut hash_input: Vec<&[u8]> = vec![
73            time_millis_be.as_ref(),
74            self.location_id.as_ref(),
75            &overflowed_bytes,
76            &sealed_bytes,
77            &num_posts_bytes,
78        ];
79
80        for encoded_post_id in &self.encoded_post_ids {
81            hash_input.push(encoded_post_id.as_ref());
82        }
83        for length_be in &encoded_post_lengths_be {
84            hash_input.push(length_be.as_ref());
85        }
86        let mut healed_ids_sorted: Vec<Id> = self.encoded_post_healed.iter().copied().collect();
87        healed_ids_sorted.sort();
88        for healed_id in &healed_ids_sorted {
89            hash_input.push(healed_id.as_ref());
90        }
91        hash_input.push(peer_hash.as_ref());
92
93        Ok(hashing::hash_multiple(&hash_input))
94    }
95
96    pub fn signature_generate(&mut self, signature_key: &SignatureKey) -> anyhow::Result<()> {
97        let hash = self.get_hash_for_signing()?;
98        self.signature = signing::sign(signature_key, hash.as_ref());
99        Ok(())
100    }
101
102    pub fn signature_verify(&self) -> anyhow::Result<()> {
103        let hash = self.get_hash_for_signing()?;
104        let verification_key = VerificationKey::from_bytes(&self.peer.verification_key_bytes)?;
105        signing::verify(&verification_key, &self.signature, hash.as_ref())
106    }
107
108    pub fn verify(&self) -> anyhow::Result<()> {
109        anyhow_assert_eq!(self.num_posts, self.encoded_post_lengths.len() as u8);
110        anyhow_assert_eq!(self.num_posts, self.encoded_post_ids.len() as u8);
111        for healed_id in &self.encoded_post_healed {
112            if !self.encoded_post_ids.contains(healed_id) {
113                anyhow::bail!("encoded_post_healed contains id not in encoded_post_ids: {}", healed_id);
114            }
115        }
116        self.signature_verify()?;
117        Ok(())
118    }
119}
120
121impl Display for EncodedPostBundleHeaderV1 {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        write!(f, "EncodedPostBundleHeaderV1 [ location_id: {}, time_millis: {}, num_posts: {}, overflowed: {}, sealed: {} ]", self.location_id, self.time_millis, self.num_posts, self.overflowed, self.sealed)
124    }
125}
126
127#[derive(Debug, PartialEq, Clone)]
128pub struct EncodedPostBundleV1 {
129    pub header: EncodedPostBundleHeaderV1,
130    pub encoded_posts_bytes: Bytes,
131}
132
133impl Display for EncodedPostBundleV1 {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        write!(f, "EncodedPostBundleV1 [ header: {}, length: {} ]", self.header, self.encoded_posts_bytes.len())
136    }
137}
138
139impl EncodedPostBundleV1 {
140    pub fn to_bytes(&self) -> anyhow::Result<Bytes> {
141        let mut bytes = BytesMut::new();
142
143        let json_post_bundle_header = json::struct_to_bytes(&self.header)?;
144        bytes.put_u8(1u8); // Version
145        bytes.put_u64(json_post_bundle_header.len() as u64);
146        bytes.put_u64(self.encoded_posts_bytes.len() as u64);
147        bytes.put_slice(json_post_bundle_header.as_ref());
148        bytes.put_slice(self.encoded_posts_bytes.as_ref());
149
150        Ok(bytes.freeze())
151    }
152
153    pub fn from_bytes(mut bytes: Bytes, decode_body: bool) -> anyhow::Result<Self> {
154        anyhow_assert_ge!(bytes.remaining(), 1, "Missing version");
155        let version = bytes.get_u8();
156        anyhow_assert_eq!(1, version, "Invalid version");
157
158        anyhow_assert_ge!(bytes.remaining(), 8, "Missing header length");
159        let header_len = bytes.get_u64() as usize;
160        anyhow_assert_ge!(bytes.remaining(), 8, "Missing body length");
161        let body_len = bytes.get_u64() as usize;
162
163        let total_length = header_len.checked_add(body_len).ok_or_else(|| anyhow::anyhow!("header_len + body_len overflow"))?;
164        anyhow_assert_ge!(bytes.remaining(), total_length, "Truncated post bundle data");
165
166        let header_bytes = bytes.copy_to_bytes(header_len);
167        let header = json::bytes_to_struct(&header_bytes)?;
168
169        let body = match decode_body {
170            true => {
171                let body_bytes = bytes.copy_to_bytes(body_len);
172                anyhow_assert_eq!(bytes.remaining(), 0, "Excess data");
173                body_bytes
174            },
175            false => Bytes::new(),
176        };
177
178        Ok(EncodedPostBundleV1 {
179            header,
180            encoded_posts_bytes: body,
181        })
182    }
183
184    /// Verifies that this bundle is legitimate enough to cache.
185    ///
186    /// Checks:
187    /// 1. The bundle header is structurally valid and its signature verifies.
188    /// 2. The sum of `encoded_post_lengths` exactly spans `encoded_posts_bytes`, and
189    ///    the plaintext `post_id` prefix of each slice matches the corresponding entry
190    ///    in `encoded_post_ids`.
191    /// 3. Each post slice can be decrypted using `base_id`, which also verifies the
192    ///    per-post signature and hash integrity.
193    pub fn verify(&self, base_id: &Id) -> anyhow::Result<()> {
194        self.verify_and_decode_post_headers(base_id)?;
195        Ok(())
196    }
197
198    /// Verifies that this bundle is a legitimate bundle for `bucket_location`, as received from the network.
199    ///
200    /// On top of [`EncodedPostBundleV1::verify`]:
201    /// 1. The publishing peer is a genuine server identity (server-id PoW, id derived from its key, self-signed).
202    ///    [`EncodedPostBundleHeaderV1::verify`] alone only checks the header against the key the header itself supplies.
203    /// 2. The bundle is for this bucket's location_id.
204    /// 3. No post appears twice.
205    /// 4. The header timestamp is plausible: not before the bucket starts and not in the future (both allowing for clock drift).
206    /// 5. Every post could legitimately have been admitted into this bucket - see [`EncodedPostV1::check_admissible_to_bucket`].
207    ///
208    /// An empty bundle can pass: whether an empty bundle is acceptable is up to the caller.
209    /// The `sealed` / `overflowed` flags are not checked here - see [`EncodedPostBundleV1::normalise_flags_for_bucket`].
210    pub fn verify_for_bucket(&self, bucket_location: &BucketLocation, time_millis_now: TimeMillis) -> anyhow::Result<()> {
211        self.header.peer.verify().map_err(|e| anyhow::anyhow!("bundle peer {} failed verification: {}", self.header.peer.id, e))?;
212
213        if self.header.location_id != bucket_location.location_id {
214            anyhow::bail!("bundle location_id {} does not match bucket location_id {}", self.header.location_id, bucket_location.location_id);
215        }
216
217        let unique_post_ids: HashSet<&Id> = self.header.encoded_post_ids.iter().collect();
218        if unique_post_ids.len() != self.header.encoded_post_ids.len() {
219            anyhow::bail!("bundle contains duplicate post ids");
220        }
221
222        if self.header.time_millis + config::CLIENT_POST_TIMESTAMP_DELTA_THRESHOLD < bucket_location.bucket_time_millis {
223            anyhow::bail!("bundle time_millis {} is before its bucket starts at {}", self.header.time_millis, bucket_location.bucket_time_millis);
224        }
225        if self.header.time_millis > time_millis_now + config::CLIENT_POST_TIMESTAMP_DELTA_THRESHOLD {
226            anyhow::bail!("bundle time_millis {} is in the future (now {})", self.header.time_millis, time_millis_now);
227        }
228
229        let decoded_posts = self.verify_and_decode_post_headers(&bucket_location.base_id)?;
230        for decoded_post in &decoded_posts {
231            decoded_post.check_admissible_to_bucket(bucket_location, self.header.time_millis)?;
232        }
233
234        Ok(())
235    }
236
237    /// Replaces the publisher-asserted `overflowed` / `sealed` flags with values the receiver can stand behind:
238    /// - a bundle holding more posts than the overflow threshold is overflowed, whatever it says,
239    /// - `sealed` is recomputed from the bucket and the (already bounds-checked) header timestamp, as the server would have.
240    ///
241    /// This deliberately normalises rather than rejects: the posts in such a bundle are still genuine, and older servers
242    /// can legitimately produce inconsistent flags (e.g. by healing posts in without recomputing them).
243    /// The header timestamp is clamped to `time_millis_now`: `verify_for_bucket` tolerates it being up to a clock drift in our future,
244    /// which must not let a publisher seal its bundle (and so pin it in caches with no expiry) before the bucket has really sealed.
245    /// NB: the header signature no longer covers the flags after this, so only use the result locally - never forward it.
246    pub fn normalise_flags_for_bucket(&mut self, bucket_location: &BucketLocation, time_millis_now: TimeMillis) {
247        self.header.overflowed |= self.header.num_posts > config::ENCODED_POST_BUNDLE_V1_OVERFLOWED_NUM_POSTS;
248        self.header.sealed = bucket_location.is_sealed_at(self.header.time_millis.min(time_millis_now), self.header.overflowed);
249    }
250
251    fn verify_and_decode_post_headers(&self, base_id: &Id) -> anyhow::Result<Vec<EncodedPostV1>> {
252        // (1) Header structure and signature
253        self.header.verify()?;
254
255        // (2) Lengths must exactly span the body
256        let total_length: usize = self.header.encoded_post_lengths.iter().sum();
257        if total_length != self.encoded_posts_bytes.len() {
258            anyhow::bail!(
259                "sum of encoded_post_lengths ({}) != encoded_posts_bytes length ({})",
260                total_length,
261                self.encoded_posts_bytes.len()
262            );
263        }
264
265        // (2) + (3) Per-post checks
266        let mut decoded_posts = Vec::with_capacity(self.header.encoded_post_ids.len());
267        let mut offset = 0usize;
268        for (i, (&length, expected_post_id)) in self.header.encoded_post_lengths.iter().zip(self.header.encoded_post_ids.iter()).enumerate() {
269            let post_bytes = self.encoded_posts_bytes.slice(offset..offset + length);
270
271            // Plaintext post_id prefix must match the header's claim before we do any crypto
272            if post_bytes.len() < ID_BYTES {
273                anyhow::bail!("post {}: bytes too short to contain post_id", i);
274            }
275            let actual_post_id = Id::from_slice(&post_bytes[..ID_BYTES])?;
276            if actual_post_id != *expected_post_id {
277                anyhow::bail!("post {}: id mismatch — header claims {} but bytes contain {}", i, expected_post_id, actual_post_id);
278            }
279
280            // Decrypt header (expect_body=true, decode_body=false): verifies per-post signature and hashes
281            let decoded_post = EncodedPostV1::decode_from_bytes(post_bytes, base_id, true, false)
282                .map_err(|e| anyhow::anyhow!("post {}: failed to verify with base_id: {}", i, e))?;
283            decoded_posts.push(decoded_post);
284
285            offset += length;
286        }
287
288        Ok(decoded_posts)
289    }
290}
291
292
293#[cfg(test)]
294pub(crate) mod test_fixtures {
295    use super::*;
296    use crate::client::key_locker::key_locker::{KeyLocker, KeyLockerManager};
297    use crate::client::key_locker::mem_key_locker::MemKeyLockerManager;
298    use crate::tools::buckets::BucketType;
299    use crate::tools::pow_generator::single_threaded_pow_generator::SingleThreadedPowGenerator;
300    use crate::tools::server_id::ServerId;
301    use crate::tools::time::MILLIS_IN_YEAR;
302    use crate::tools::time_provider::time_provider::{RealTimeProvider, TimeProvider};
303    use std::sync::Arc;
304
305    /// A genuine server, a genuine client, and that client's current User bucket.
306    pub(crate) struct BucketBundleFixture {
307        pub(crate) server_id: ServerId,
308        pub(crate) key_locker: Arc<dyn KeyLocker>,
309        pub(crate) bucket_location: BucketLocation,
310        pub(crate) time_millis_now: TimeMillis,
311    }
312
313    impl BucketBundleFixture {
314        pub(crate) async fn new() -> anyhow::Result<Self> {
315            let time_provider = RealTimeProvider;
316            let pow_generator = SingleThreadedPowGenerator::new();
317            let server_id = ServerId::new("bucket_bundle", &time_provider, config::SERVER_KEY_POW_MIN, true, &pow_generator).await?;
318            let key_locker_manager = MemKeyLockerManager::new().await?;
319            let key_locker: Arc<dyn KeyLocker> = key_locker_manager.create("bucket bundle keyphrase".to_string()).await?;
320            let time_millis_now = time_provider.current_time_millis();
321            let bucket_location = BucketLocation::new(BucketType::User, key_locker.client_id().id, MILLIS_IN_YEAR, time_millis_now)?;
322            Ok(Self { server_id, key_locker, bucket_location, time_millis_now })
323        }
324
325        pub(crate) async fn make_post(&self, timestamp: TimeMillis, linked_base_ids: Vec<Id>) -> anyhow::Result<(Id, Bytes)> {
326            let mut encoded_post = EncodedPostV1::new(self.key_locker.client_id(), timestamp, linked_base_ids, "bucket bundle post");
327            let encoded_post_bytes = encoded_post.encode_to_bytes_direct(&self.key_locker).await?;
328            Ok((encoded_post.post_id, Bytes::copy_from_slice(encoded_post_bytes.bytes())))
329        }
330
331        pub(crate) async fn make_own_posts(&self, num_posts: usize) -> anyhow::Result<Vec<(Id, Bytes)>> {
332            let mut posts = Vec::with_capacity(num_posts);
333            for _ in 0..num_posts {
334                posts.push(self.make_post(self.time_millis_now, vec![self.bucket_location.base_id]).await?);
335            }
336            Ok(posts)
337        }
338
339        pub(crate) fn make_bundle_signed_by(&self, server_id: &ServerId, peer: Peer, time_millis: TimeMillis, posts: &[(Id, Bytes)]) -> anyhow::Result<EncodedPostBundleV1> {
340            let mut encoded_posts_bytes = BytesMut::new();
341            for (_, post_bytes) in posts {
342                encoded_posts_bytes.put_slice(post_bytes);
343            }
344            let mut header = EncodedPostBundleHeaderV1 {
345                time_millis,
346                location_id: self.bucket_location.location_id,
347                overflowed: false,
348                sealed: false,
349                num_posts: posts.len() as u8,
350                encoded_post_ids: posts.iter().map(|(post_id, _)| *post_id).collect(),
351                encoded_post_lengths: posts.iter().map(|(_, post_bytes)| post_bytes.len()).collect(),
352                encoded_post_healed: HashSet::new(),
353                peer,
354                signature: Signature::zero(),
355            };
356            header.signature_generate(&server_id.keys.signature_key)?;
357            Ok(EncodedPostBundleV1 { header, encoded_posts_bytes: encoded_posts_bytes.freeze() })
358        }
359
360        pub(crate) fn make_bundle(&self, posts: &[(Id, Bytes)]) -> anyhow::Result<EncodedPostBundleV1> {
361            let peer = self.server_id.to_peer(&RealTimeProvider)?;
362            self.make_bundle_signed_by(&self.server_id, peer, self.time_millis_now, posts)
363        }
364
365        pub(crate) fn resign(&self, encoded_post_bundle: &mut EncodedPostBundleV1) -> anyhow::Result<()> {
366            encoded_post_bundle.header.signature_generate(&self.server_id.keys.signature_key)
367        }
368
369        /// Another genuine server identity, to play a second (third, ...) originator.
370        pub(crate) async fn new_server_id() -> anyhow::Result<ServerId> {
371            ServerId::new("bucket_bundle", &RealTimeProvider, config::SERVER_KEY_POW_MIN, true, &SingleThreadedPowGenerator::new()).await
372        }
373
374        pub(crate) fn make_bundle_from(&self, server_id: &ServerId, posts: &[(Id, Bytes)]) -> anyhow::Result<EncodedPostBundleV1> {
375            self.make_bundle_signed_by(server_id, server_id.to_peer(&RealTimeProvider)?, self.time_millis_now, posts)
376        }
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use super::test_fixtures::BucketBundleFixture;
384    use crate::client::key_locker::key_locker::{KeyLocker, KeyLockerManager};
385    use crate::client::key_locker::mem_key_locker::MemKeyLockerManager;
386    use std::sync::Arc;
387    use crate::protocol::posting::encoded_post::EncodedPostV1;
388    use crate::tools::buckets::BucketType;
389    use crate::tools::server_id::ServerId;
390    use crate::tools::time::{MILLIS_IN_DAY, MILLIS_IN_MINUTE, MILLIS_IN_SECOND};
391    use crate::tools::time_provider::time_provider::{RealTimeProvider, TimeProvider};
392    use crate::tools::tools;
393    use crate::tools::types::Pow;
394    use crate::tools::pow_generator::single_threaded_pow_generator::SingleThreadedPowGenerator;
395
396    /// Builds a valid single-post bundle encrypted under `base_id`.
397    async fn make_valid_bundle(base_id: Id) -> anyhow::Result<EncodedPostBundleV1> {
398        let time_provider = RealTimeProvider;
399        let pow_generator = SingleThreadedPowGenerator::new();
400        let server_id = ServerId::new("own_pow", &time_provider, Pow(0), true, &pow_generator).await?;
401        let peer = server_id.to_peer(&time_provider)?;
402
403        let key_locker_manager = MemKeyLockerManager::new().await?;
404        let key_locker: Arc<dyn KeyLocker> = key_locker_manager.create("test keyphrase".to_string()).await?;
405        let client_id = key_locker.client_id();
406        let timestamp = time_provider.current_time_millis();
407
408        let mut encoded_post = EncodedPostV1::new(client_id, timestamp, vec![base_id], "test post content");
409        let post_bytes_obj = encoded_post.encode_to_bytes_direct(&key_locker).await?;
410        let post_bytes = Bytes::copy_from_slice(post_bytes_obj.bytes());
411
412        let mut header = EncodedPostBundleHeaderV1 {
413            time_millis: timestamp,
414            location_id: Id::random(),
415            overflowed: false,
416            sealed: false,
417            num_posts: 1,
418            encoded_post_ids: vec![encoded_post.post_id],
419            encoded_post_lengths: vec![post_bytes.len()],
420            encoded_post_healed: HashSet::new(),
421            peer,
422            signature: Signature::zero(),
423        };
424        header.signature_generate(&server_id.keys.signature_key)?;
425
426        Ok(EncodedPostBundleV1 { header, encoded_posts_bytes: post_bytes })
427    }
428
429    #[tokio::test]
430    async fn test_verify_valid_bundle() -> anyhow::Result<()> {
431        let base_id = Id::random();
432        let bundle = make_valid_bundle(base_id).await?;
433        bundle.verify(&base_id)
434    }
435
436    #[tokio::test]
437    async fn test_verify_wrong_base_id() -> anyhow::Result<()> {
438        let base_id = Id::random();
439        let bundle = make_valid_bundle(base_id).await?;
440        let wrong_base_id = Id::random();
441        assert!(bundle.verify(&wrong_base_id).is_err());
442        Ok(())
443    }
444
445    #[tokio::test]
446    async fn test_verify_tampered_post_bytes() -> anyhow::Result<()> {
447        let base_id = Id::random();
448        let bundle = make_valid_bundle(base_id).await?;
449        let mut tampered_posts = bundle.encoded_posts_bytes.to_vec();
450        tampered_posts[ID_BYTES + 10] ^= 0xff; // flip a byte inside the encrypted header
451        let tampered_bundle = EncodedPostBundleV1 {
452            header: bundle.header,
453            encoded_posts_bytes: Bytes::from(tampered_posts),
454        };
455        assert!(tampered_bundle.verify(&base_id).is_err());
456        Ok(())
457    }
458
459    #[tokio::test]
460    async fn test_verify_wrong_post_id_in_header() -> anyhow::Result<()> {
461        let base_id = Id::random();
462        let mut bundle = make_valid_bundle(base_id).await?;
463        let pow_generator = SingleThreadedPowGenerator::new();
464        let server_id = ServerId::new("own_pow", &RealTimeProvider, Pow(0), true, &pow_generator).await?;
465        bundle.header.encoded_post_ids[0] = Id::random(); // wrong post_id
466        bundle.header.signature_generate(&server_id.keys.signature_key)?;
467        assert!(bundle.verify(&base_id).is_err());
468        Ok(())
469    }
470
471    #[tokio::test]
472    async fn test_verify_wrong_length_sum() -> anyhow::Result<()> {
473        let base_id = Id::random();
474        let mut bundle = make_valid_bundle(base_id).await?;
475        let pow_generator = SingleThreadedPowGenerator::new();
476        let server_id = ServerId::new("own_pow", &RealTimeProvider, Pow(0), true, &pow_generator).await?;
477        bundle.header.encoded_post_lengths[0] += 1; // length doesn't match bytes
478        bundle.header.signature_generate(&server_id.keys.signature_key)?;
479        assert!(bundle.verify(&base_id).is_err());
480        Ok(())
481    }
482
483    #[tokio::test]
484    async fn test_verify_tampered_post_length() -> anyhow::Result<()> {
485        // Changing encoded_post_lengths must invalidate the signature even when the
486        // structural checks (sum == body length, num_posts == lengths.len()) still pass.
487        let base_id = Id::random();
488        let bundle = make_valid_bundle(base_id).await?;
489        let original_length = bundle.header.encoded_post_lengths[0];
490        // Pad the body so the sum still matches, then bump the recorded length.
491        let mut tampered_posts = bundle.encoded_posts_bytes.to_vec();
492        tampered_posts.push(0u8); // one extra byte on the body
493        let mut tampered_header = bundle.header.clone();
494        tampered_header.encoded_post_lengths[0] = original_length + 1;
495        // Do NOT re-sign — the signature now covers a different lengths list.
496        let tampered_bundle = EncodedPostBundleV1 {
497            header: tampered_header,
498            encoded_posts_bytes: Bytes::from(tampered_posts),
499        };
500        assert!(tampered_bundle.verify(&base_id).is_err());
501        Ok(())
502    }
503
504    #[tokio::test]
505    async fn test_verify_bad_header_signature() -> anyhow::Result<()> {
506        let base_id = Id::random();
507        let mut bundle = make_valid_bundle(base_id).await?;
508        bundle.header.signature = Signature::zero(); // corrupt the bundle header signature
509        assert!(bundle.verify(&base_id).is_err());
510        Ok(())
511    }
512
513    // ── verify_for_bucket / normalise_flags_for_bucket ──
514
515    #[tokio::test]
516    async fn test_verify_for_bucket_accepts_genuine_bundles() -> anyhow::Result<()> {
517        let fixture = BucketBundleFixture::new().await?;
518        let posts = fixture.make_own_posts(2).await?;
519        fixture.make_bundle(&posts)?.verify_for_bucket(&fixture.bucket_location, fixture.time_millis_now)?;
520
521        // Empty bundles pass too - it is up to the caller whether they count for anything
522        fixture.make_bundle(&[])?.verify_for_bucket(&fixture.bucket_location, fixture.time_millis_now)?;
523        Ok(())
524    }
525
526    #[tokio::test]
527    async fn test_verify_for_bucket_rejects_peer_without_server_pow() -> anyhow::Result<()> {
528        let fixture = BucketBundleFixture::new().await?;
529        let posts = fixture.make_own_posts(1).await?;
530
531        // A freshly generated keypair with no meaningful PoW behind it: the header signature is fine, but it is not a genuine server
532        let pow_generator = SingleThreadedPowGenerator::new();
533        let unworked_server_id = loop {
534            let server_id = ServerId::new("unworked", &RealTimeProvider, Pow(0), true, &pow_generator).await?;
535            if server_id.pow < config::SERVER_KEY_POW_MIN {
536                break server_id;
537            }
538        };
539        let unworked_peer = unworked_server_id.to_peer(&RealTimeProvider)?;
540        let encoded_post_bundle = fixture.make_bundle_signed_by(&unworked_server_id, unworked_peer, fixture.time_millis_now, &posts)?;
541        encoded_post_bundle.verify(&fixture.bucket_location.base_id)?; // The old check is happy...
542        assert!(encoded_post_bundle.verify_for_bucket(&fixture.bucket_location, fixture.time_millis_now).is_err()); // ...the new one is not
543        Ok(())
544    }
545
546    #[tokio::test]
547    async fn test_verify_for_bucket_rejects_peer_impersonating_another_id() -> anyhow::Result<()> {
548        let fixture = BucketBundleFixture::new().await?;
549        let posts = fixture.make_own_posts(1).await?;
550
551        // Claim someone else's server id while signing everything with our own key
552        let mut impersonating_peer = fixture.server_id.to_peer(&RealTimeProvider)?;
553        impersonating_peer.id = Id::random();
554        impersonating_peer.sign(&RealTimeProvider, &fixture.server_id.keys.signature_key)?;
555        let encoded_post_bundle = fixture.make_bundle_signed_by(&fixture.server_id, impersonating_peer, fixture.time_millis_now, &posts)?;
556        encoded_post_bundle.verify(&fixture.bucket_location.base_id)?;
557        assert!(encoded_post_bundle.verify_for_bucket(&fixture.bucket_location, fixture.time_millis_now).is_err());
558        Ok(())
559    }
560
561    #[tokio::test]
562    async fn test_verify_for_bucket_rejects_wrong_location() -> anyhow::Result<()> {
563        let fixture = BucketBundleFixture::new().await?;
564        let posts = fixture.make_own_posts(1).await?;
565        let mut encoded_post_bundle = fixture.make_bundle(&posts)?;
566        encoded_post_bundle.header.location_id = Id::random();
567        fixture.resign(&mut encoded_post_bundle)?;
568        assert!(encoded_post_bundle.verify_for_bucket(&fixture.bucket_location, fixture.time_millis_now).is_err());
569        Ok(())
570    }
571
572    #[tokio::test]
573    async fn test_verify_for_bucket_rejects_duplicate_post_ids() -> anyhow::Result<()> {
574        let fixture = BucketBundleFixture::new().await?;
575        let posts = fixture.make_own_posts(1).await?;
576        let duplicated_posts = vec![posts[0].clone(), posts[0].clone()];
577        let encoded_post_bundle = fixture.make_bundle(&duplicated_posts)?;
578        encoded_post_bundle.verify(&fixture.bucket_location.base_id)?;
579        assert!(encoded_post_bundle.verify_for_bucket(&fixture.bucket_location, fixture.time_millis_now).is_err());
580        Ok(())
581    }
582
583    #[tokio::test]
584    async fn test_verify_for_bucket_rejects_implausible_header_time() -> anyhow::Result<()> {
585        let fixture = BucketBundleFixture::new().await?;
586        let peer = fixture.server_id.to_peer(&RealTimeProvider)?;
587
588        let future_time_millis = fixture.time_millis_now + config::CLIENT_POST_TIMESTAMP_DELTA_THRESHOLD + MILLIS_IN_SECOND;
589        let future_encoded_post_bundle = fixture.make_bundle_signed_by(&fixture.server_id, peer.clone(), future_time_millis, &[])?;
590        assert!(future_encoded_post_bundle.verify_for_bucket(&fixture.bucket_location, fixture.time_millis_now).is_err());
591
592        let before_bucket_time_millis = fixture.bucket_location.bucket_time_millis - config::CLIENT_POST_TIMESTAMP_DELTA_THRESHOLD - MILLIS_IN_SECOND;
593        let before_bucket_encoded_post_bundle = fixture.make_bundle_signed_by(&fixture.server_id, peer, before_bucket_time_millis, &[])?;
594        assert!(before_bucket_encoded_post_bundle.verify_for_bucket(&fixture.bucket_location, fixture.time_millis_now).is_err());
595        Ok(())
596    }
597
598    #[tokio::test]
599    async fn test_verify_for_bucket_rejects_inadmissible_posts() -> anyhow::Result<()> {
600        let fixture = BucketBundleFixture::new().await?;
601        let own_posts = fixture.make_own_posts(1).await?;
602
603        // A genuine, correctly signed post by the same user from the previous bucket, replayed into this one
604        let old_post = fixture.make_post(fixture.bucket_location.bucket_time_millis - MILLIS_IN_SECOND, vec![fixture.bucket_location.base_id]).await?;
605        let replayed_encoded_post_bundle = fixture.make_bundle(&[own_posts[0].clone(), old_post])?;
606        replayed_encoded_post_bundle.verify(&fixture.bucket_location.base_id)?;
607        assert!(replayed_encoded_post_bundle.verify_for_bucket(&fixture.bucket_location, fixture.time_millis_now).is_err());
608
609        // A post that claims to be newer than the bundle that supposedly already held it
610        let post_after_bundle = fixture.make_post(fixture.time_millis_now + config::CLIENT_POST_TIMESTAMP_DELTA_THRESHOLD, vec![fixture.bucket_location.base_id]).await?;
611        let peer = fixture.server_id.to_peer(&RealTimeProvider)?;
612        let stale_header_encoded_post_bundle = fixture.make_bundle_signed_by(&fixture.server_id, peer, fixture.time_millis_now - MILLIS_IN_SECOND, &[post_after_bundle])?;
613        assert!(stale_header_encoded_post_bundle.verify_for_bucket(&fixture.bucket_location, fixture.time_millis_now + config::CLIENT_POST_TIMESTAMP_DELTA_THRESHOLD).is_err());
614        Ok(())
615    }
616
617    #[tokio::test]
618    async fn test_verify_for_bucket_rejects_foreign_author_in_user_bucket() -> anyhow::Result<()> {
619        let fixture = BucketBundleFixture::new().await?;
620
621        // Another client links our user id so that their post decrypts under our base_id
622        let other_key_locker_manager = MemKeyLockerManager::new().await?;
623        let other_key_locker: Arc<dyn KeyLocker> = other_key_locker_manager.create("other keyphrase".to_string()).await?;
624        let mut foreign_post = EncodedPostV1::new(other_key_locker.client_id(), fixture.time_millis_now, vec![fixture.bucket_location.base_id], "not your post");
625        let foreign_post_bytes = foreign_post.encode_to_bytes_direct(&other_key_locker).await?;
626
627        let encoded_post_bundle = fixture.make_bundle(&[(foreign_post.post_id, Bytes::copy_from_slice(foreign_post_bytes.bytes()))])?;
628        encoded_post_bundle.verify(&fixture.bucket_location.base_id)?;
629        assert!(encoded_post_bundle.verify_for_bucket(&fixture.bucket_location, fixture.time_millis_now).is_err());
630        Ok(())
631    }
632
633    #[tokio::test]
634    async fn test_normalise_flags_for_bucket() -> anyhow::Result<()> {
635        let fixture = BucketBundleFixture::new().await?;
636
637        // A premature "sealed" claim is dropped
638        let posts = fixture.make_own_posts(1).await?;
639        let mut prematurely_sealed_encoded_post_bundle = fixture.make_bundle(&posts)?;
640        prematurely_sealed_encoded_post_bundle.header.sealed = true;
641        prematurely_sealed_encoded_post_bundle.normalise_flags_for_bucket(&fixture.bucket_location, fixture.time_millis_now);
642        assert!(!prematurely_sealed_encoded_post_bundle.header.sealed);
643        assert!(!prematurely_sealed_encoded_post_bundle.header.overflowed);
644
645        // An overflowed bundle is legitimately sealed early
646        let mut overflowed_encoded_post_bundle = fixture.make_bundle(&posts)?;
647        overflowed_encoded_post_bundle.header.overflowed = true;
648        overflowed_encoded_post_bundle.normalise_flags_for_bucket(&fixture.bucket_location, fixture.time_millis_now);
649        assert!(overflowed_encoded_post_bundle.header.overflowed);
650        assert!(overflowed_encoded_post_bundle.header.sealed);
651
652        // Too many posts to not be overflowed, whatever the publisher claims
653        let many_posts = fixture.make_own_posts(config::ENCODED_POST_BUNDLE_V1_OVERFLOWED_NUM_POSTS as usize + 1).await?;
654        let mut understated_encoded_post_bundle = fixture.make_bundle(&many_posts)?;
655        understated_encoded_post_bundle.verify_for_bucket(&fixture.bucket_location, fixture.time_millis_now)?;
656        understated_encoded_post_bundle.normalise_flags_for_bucket(&fixture.bucket_location, fixture.time_millis_now);
657        assert!(understated_encoded_post_bundle.header.overflowed);
658        assert!(understated_encoded_post_bundle.header.sealed);
659
660        // A bundle published after the bucket's grace period is sealed even if it forgot to say so
661        let past_bucket_location = BucketLocation::new(BucketType::User, fixture.bucket_location.base_id, MILLIS_IN_DAY, fixture.time_millis_now - MILLIS_IN_DAY.const_mul(2))?;
662        let mut unsealed_past_encoded_post_bundle = fixture.make_bundle(&[])?;
663        unsealed_past_encoded_post_bundle.header.location_id = past_bucket_location.location_id;
664        unsealed_past_encoded_post_bundle.normalise_flags_for_bucket(&past_bucket_location, fixture.time_millis_now);
665        assert!(unsealed_past_encoded_post_bundle.header.sealed);
666
667        // A header dated into our future (within the drift verify_for_bucket allows) cannot seal the bundle before the bucket really seals
668        let bucket_location = &fixture.bucket_location;
669        let seal_time_millis = bucket_location.bucket_time_millis + bucket_location.duration + config::ENCODED_POST_BUNDLE_V1_ELAPSED_THRESHOLD_MILLIS;
670        let time_millis_before_seal = seal_time_millis - MILLIS_IN_MINUTE.const_mul(5);
671        let mut future_dated_encoded_post_bundle = fixture.make_bundle(&posts)?;
672        future_dated_encoded_post_bundle.header.time_millis = time_millis_before_seal + MILLIS_IN_MINUTE.const_mul(9);
673        future_dated_encoded_post_bundle.header.sealed = true;
674        let mut normalised_before_seal = future_dated_encoded_post_bundle.clone();
675        normalised_before_seal.normalise_flags_for_bucket(bucket_location, time_millis_before_seal);
676        assert!(!normalised_before_seal.header.sealed, "a future-dated header must not seal the bundle early");
677        let mut normalised_after_seal = future_dated_encoded_post_bundle.clone();
678        normalised_after_seal.normalise_flags_for_bucket(bucket_location, seal_time_millis + MILLIS_IN_MINUTE);
679        assert!(normalised_after_seal.header.sealed, "once the bucket has really sealed, the bundle is sealed");
680        Ok(())
681    }
682
683    #[tokio::test]
684    async fn encoded_post_bundle_v1_to_from_bytes_roundtrip() -> anyhow::Result<()> {
685        let time_provider = RealTimeProvider;
686        let pow_generator = SingleThreadedPowGenerator::new();
687        let server_id = ServerId::new("own_pow", &time_provider, Pow(0), true, &pow_generator).await?;
688        let peer = server_id.to_peer(&time_provider)?;
689
690        let num_posts: u8 = 3;
691
692        let mut header = EncodedPostBundleHeaderV1 {
693            time_millis: TimeMillis::random(),
694            location_id: Id::random(),
695            overflowed: true,
696            sealed: false,
697            num_posts,
698            encoded_post_ids: (0..num_posts).map(|_| Id::random()).collect(),
699            encoded_post_lengths: (0..num_posts).map(|_| tools::random_usize_bounded(1024)).collect(),
700            encoded_post_healed: HashSet::new(),
701            peer,
702            signature: Signature::zero(),
703        };
704
705        header.signature_generate(&server_id.keys.signature_key)?;
706        header.verify()?;
707
708        let total_bytes = header.encoded_post_lengths.iter().sum::<usize>();
709        let encoded_posts_bytes = Bytes::from(tools::random_bytes(total_bytes));
710
711        let bundle = EncodedPostBundleV1 {
712            header,
713            encoded_posts_bytes,
714        };
715
716        let bytes1 = bundle.to_bytes()?;
717        let decoded = EncodedPostBundleV1::from_bytes(bytes1.clone(), true)?;
718
719        assert_eq!(bundle, decoded);
720
721        // Optional extra sanity check: encoding the decoded struct should be stable.
722        let bytes2 = decoded.to_bytes()?;
723        assert_eq!(bytes1, bytes2);
724
725        Ok(())
726    }
727
728    #[tokio::test]
729    async fn encoded_post_bundle_v1_to_from_bytes_roundtrip_without_body() -> anyhow::Result<()> {
730        let time_provider = RealTimeProvider;
731        let pow_generator = SingleThreadedPowGenerator::new();
732        let server_id = ServerId::new("own_pow", &time_provider, Pow(0), true, &pow_generator).await?;
733        let peer = server_id.to_peer(&time_provider)?;
734
735        let num_posts: u8 = 3;
736
737        let mut header = EncodedPostBundleHeaderV1 {
738            time_millis: TimeMillis::random(),
739            location_id: Id::random(),
740            overflowed: true,
741            sealed: false,
742            num_posts,
743            encoded_post_ids: (0..num_posts).map(|_| Id::random()).collect(),
744            encoded_post_lengths: (0..num_posts).map(|_| tools::random_usize_bounded(1024)).collect(),
745            encoded_post_healed: HashSet::new(),
746            peer,
747            signature: Signature::zero(),
748        };
749
750        header.signature_generate(&server_id.keys.signature_key)?;
751        header.verify()?;
752
753        let total_bytes = header.encoded_post_lengths.iter().sum::<usize>();
754        let encoded_posts_bytes = Bytes::from(tools::random_bytes(total_bytes));
755
756        let bundle = EncodedPostBundleV1 {
757            header,
758            encoded_posts_bytes,
759        };
760
761        let bytes1 = bundle.to_bytes()?;
762        let decoded = EncodedPostBundleV1::from_bytes(bytes1.clone(), false)?;
763
764        assert_eq!(bundle.header, decoded.header);
765        assert!(decoded.encoded_posts_bytes.is_empty());
766
767        Ok(())
768    }
769
770    // ── Robustness tests: EncodedPostBundleV1::from_bytes ──
771
772    #[test]
773    fn test_from_bytes_empty() {
774        assert!(EncodedPostBundleV1::from_bytes(Bytes::new(), true).is_err());
775    }
776
777    #[test]
778    fn test_from_bytes_wrong_version() {
779        assert!(EncodedPostBundleV1::from_bytes(Bytes::from_static(&[99u8]), true).is_err());
780    }
781
782    #[test]
783    fn test_from_bytes_truncated_at_header_length() {
784        // Version byte only, no header length u64
785        assert!(EncodedPostBundleV1::from_bytes(Bytes::from_static(&[1u8]), true).is_err());
786    }
787
788    #[test]
789    fn test_from_bytes_truncated_at_body_length() {
790        // Version + header_len=0, but no body_len u64
791        let mut bytes = BytesMut::new();
792        bytes.put_u8(1); // version
793        bytes.put_u64(0); // header_len
794        assert!(EncodedPostBundleV1::from_bytes(bytes.freeze(), true).is_err());
795    }
796
797    #[test]
798    fn test_from_bytes_header_len_exceeds_remaining() {
799        let mut bytes = BytesMut::new();
800        bytes.put_u8(1); // version
801        bytes.put_u64(99999); // header_len way too large
802        bytes.put_u64(0); // body_len
803        assert!(EncodedPostBundleV1::from_bytes(bytes.freeze(), true).is_err());
804    }
805
806    #[test]
807    fn test_from_bytes_overflow_lengths() {
808        let mut bytes = BytesMut::new();
809        bytes.put_u8(1); // version
810        bytes.put_u64(u64::MAX); // header_len
811        bytes.put_u64(1); // body_len — header_len + body_len overflows usize
812        assert!(EncodedPostBundleV1::from_bytes(bytes.freeze(), true).is_err());
813    }
814
815    #[test]
816    fn test_from_bytes_garbage() {
817        assert!(EncodedPostBundleV1::from_bytes(Bytes::from_static(&[0xff; 128]), true).is_err());
818    }
819
820    #[cfg(not(target_arch = "wasm32"))]
821    mod bolero_fuzz {
822        use bytes::Bytes;
823        use crate::protocol::posting::encoded_post_bundle::EncodedPostBundleV1;
824
825        #[test]
826        fn fuzz_from_bytes() {
827            bolero::check!().for_each(|data: &[u8]| {
828                let _ = EncodedPostBundleV1::from_bytes(Bytes::copy_from_slice(data), true);
829            });
830        }
831
832        #[test]
833        fn fuzz_from_bytes_no_body() {
834            bolero::check!().for_each(|data: &[u8]| {
835                let _ = EncodedPostBundleV1::from_bytes(Bytes::copy_from_slice(data), false);
836            });
837        }
838    }
839}