Skip to main content

hashiverse_lib/protocol/posting/
encoded_post.rs

1//! # `EncodedPostV1` — individual post wire format
2//!
3//! The canonical on-the-wire representation of a single user post.
4//! [`EncodedPostV1`] pairs:
5//!
6//! - an [`EncodedPostHeaderV1`] carrying the author's keys, timestamp, linked base IDs,
7//!   and signing-authority choice,
8//! - the post body bytes (compressed + encrypted),
9//! - an Ed25519 signature (or delegated-key signature per `PostSigningAuthorityV1`).
10//!
11//! The `post_id` is the hash of the signature, which gives every post a deterministic,
12//! collision-free identifier derivable from the bytes alone. On the wire both header
13//! and body are compressed and encrypted under passwords derived from the client id
14//! and the set of linked base IDs — so that recipients who already know those keys
15//! can decrypt without an extra key exchange.
16//!
17//! [`EncodedPostV1::bytes_without_body`] returns just the header bytes, so that
18//! previews and timeline skeletons can be delivered before the full body is paid for
19//! in the reader's bandwidth budget.
20
21use crate::client::key_locker::key_locker::KeyLocker;
22use crate::tools::buckets::{BucketLocation, BucketType};
23use crate::tools::client_id::ClientId;
24use crate::tools::config;
25use crate::tools::time::TimeMillis;
26use crate::tools::types::{Hash, Id, PQCommitmentBytes, Signature, VerificationKey, VerificationKeyBytes, HASH_BYTES, ID_BYTES, SIGNATURE_BYTES};
27use crate::tools::{compression, encryption, hashing, json, signing};
28use crate::{anyhow_assert_eq, anyhow_assert_ge};
29use bytes::{Buf, BufMut, Bytes, BytesMut};
30use serde::{Deserialize, Serialize};
31use std::fmt::Debug;
32use std::sync::Arc;
33
34#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
35pub struct EncodedPostHeaderSignatureDirectV1 {}
36#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
37pub struct EncodedPostHeaderSignatureEphemeralV1 {
38    // Not yet implemented
39    // ephemeral_salt: Salt,
40    // ephemeral_signature: Signature,
41}
42
43#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
44pub struct EncodedPostHeaderSignatureDelegationV1 {
45    // Not yet implemented
46}
47
48#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
49pub struct EncodedPostHeaderSignatureMechanismV1 {
50    direct: Option<EncodedPostHeaderSignatureDirectV1>,
51    ephemeral: Option<EncodedPostHeaderSignatureEphemeralV1>,
52    delegation: Option<EncodedPostHeaderSignatureDelegationV1>,
53}
54
55#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
56pub struct EncodedPostHeaderV1 {
57    pub verification_key_bytes: VerificationKeyBytes,
58    pub pq_commitment_bytes: PQCommitmentBytes,
59    pub time_millis: TimeMillis,
60    pub post_length: usize,
61    pub linked_base_ids: Vec<Id>,
62    pub signature_mechanism: EncodedPostHeaderSignatureMechanismV1,
63}
64
65impl EncodedPostHeaderV1 {
66    pub fn client_id(&self) -> anyhow::Result<ClientId> {
67        ClientId::new(self.verification_key_bytes, self.pq_commitment_bytes)
68    }
69}
70
71/// A single user post in its canonical, verifiable form as it travels across the network.
72///
73/// `EncodedPostV1` bundles the post body together with everything a recipient needs to
74/// authenticate it: the author's [`EncodedPostHeaderV1`] (verification key, PQ commitment,
75/// timestamp, linked base IDs, and which signature mechanism is in use), the author's
76/// [`Signature`] over the canonical bytes, and a `post_id` which is simply the hash of the
77/// signature (so every post has a deterministic, collision-free identifier).
78///
79/// On the wire the post body is compressed and optionally encrypted; this struct is the
80/// decoded in-memory form. [`EncodedPostV1::bytes_without_body`] exists for callers who only
81/// need the header portion — e.g. a timeline that is showing post previews without having
82/// yet fetched the full body.
83#[derive(Debug, PartialEq, Clone)]
84pub struct EncodedPostV1 {
85    pub post_id: Id, // Simply the hash of the signature
86    pub signature: Signature,
87    pub header: EncodedPostHeaderV1,
88    pub post: String,
89}
90
91impl EncodedPostV1 {
92    /// Returns the header portion of encoded post bytes (post_id + signature + version +
93    /// hashes + lengths + encrypted_header), excluding the encrypted body.
94    /// The result is suitable for passing to `decode_from_bytes(bytes, password, false, false)`.
95    pub fn bytes_without_body(bytes: Bytes) -> anyhow::Result<Bytes> {
96        let fixed_prefix = ID_BYTES + SIGNATURE_BYTES + 1 + HASH_BYTES * 2;
97        anyhow_assert_ge!(bytes.len(), fixed_prefix + 4 + 4, "Bytes too short for header");
98        let header_encrypted_length = u32::from_be_bytes(bytes[fixed_prefix..fixed_prefix + 4].try_into()?) as usize;
99        let length_without_body = fixed_prefix + 4 + 4 + header_encrypted_length;
100        anyhow_assert_ge!(bytes.len(), length_without_body, "Bytes too short for encrypted header");
101        Ok(bytes.slice(..length_without_body))
102    }
103
104    pub fn new(client_id: &ClientId, timestamp: TimeMillis, linked_base_ids: Vec<Id>, post: &str) -> Self {
105        let post = post.to_string();
106        let post_length = post.len();
107
108        Self {
109            post_id: Id::zero(),
110            signature: Signature::zero(),
111            header: EncodedPostHeaderV1 {
112                verification_key_bytes: client_id.verification_key_bytes,
113                pq_commitment_bytes: client_id.pq_commitment_bytes,
114                time_millis: timestamp,
115                post_length,
116                linked_base_ids,
117                signature_mechanism: EncodedPostHeaderSignatureMechanismV1 {
118                    direct: None,
119                    ephemeral: None,
120                    delegation: None,
121                },
122            },
123
124            post,
125        }
126    }
127
128    pub async fn encode_to_bytes_direct(&mut self, key_locker: &Arc<dyn KeyLocker>) -> anyhow::Result<EncodedPostBytesV1> {
129        self.header.signature_mechanism.direct = Some(EncodedPostHeaderSignatureDirectV1 {});
130
131        let mut passwords = Vec::new();
132        {
133            let client_id = ClientId::id_from_parts(&self.header.verification_key_bytes, &self.header.pq_commitment_bytes)?;
134            passwords.push(client_id.as_bytes().to_vec());
135            let mut linked_base_ids = self.header.linked_base_ids.iter().map(|id| id.as_bytes().to_vec()).collect();
136            passwords.append(&mut linked_base_ids);
137        }
138
139        let header = json::struct_to_bytes(&self.header)?;
140        let header_compressed = compression::compress_for_size(&header)?.to_bytes();
141        let header_encrypted = encryption::encrypt_weak(&header_compressed, &passwords)?;
142        let header_encrypted_hash = hashing::hash(&header_encrypted);
143
144        let post_compressed = compression::compress_for_size(self.post.as_bytes())?.to_bytes();
145        let post_encrypted = encryption::encrypt_weak(&post_compressed, &passwords)?;
146        let post_encrypted_hash = hashing::hash(&post_encrypted);
147
148        let hash = hashing::hash_multiple(&[header_encrypted_hash.as_ref(), post_encrypted_hash.as_ref()]);
149        let signature = key_locker.sign(hash.as_ref()).await?;
150        let post_id = Id::from_hash(hashing::hash(signature.as_ref()))?;
151
152        self.signature = signature;
153        self.post_id = post_id;
154
155        let mut bytes = BytesMut::new();
156        bytes.put_slice(post_id.as_ref());
157        bytes.put_slice(signature.as_ref());
158        bytes.put_u8(1u8); // Version
159        bytes.put_slice(header_encrypted_hash.as_ref());
160        bytes.put_slice(post_encrypted_hash.as_ref());
161        bytes.put_u32(header_encrypted.len() as u32);
162        bytes.put_u32(post_encrypted.len() as u32);
163        bytes.put_slice(header_encrypted.as_ref());
164        bytes.put_slice(post_encrypted.as_ref());
165
166        let bytes = bytes.freeze();
167
168        Ok(EncodedPostBytesV1 {
169            length_without_body: bytes.len() - post_encrypted.len(),
170            bytes,
171        })
172    }
173
174    pub fn decode_signature_from_bytes(bytes: &[u8]) -> anyhow::Result<Signature> {
175        anyhow::ensure!(bytes.len() >= SIGNATURE_BYTES, "decode_signature_from_bytes: need {} bytes, got {}", SIGNATURE_BYTES, bytes.len());
176        Signature::from_slice(&bytes[0..SIGNATURE_BYTES])
177    }
178
179    pub fn decode_from_bytes(mut bytes: Bytes, password_base_id: &Id, expect_body: bool, decode_body: bool) -> anyhow::Result<Self> {
180        let password = password_base_id.as_ref();
181
182        anyhow_assert_ge!(bytes.remaining(), ID_BYTES, "Missing post_id");
183        let post_id = Id::from_slice(&bytes.split_to(ID_BYTES))?;
184
185        anyhow_assert_ge!(bytes.remaining(), SIGNATURE_BYTES, "Missing signature");
186        let signature = Signature::from_slice(&bytes.split_to(SIGNATURE_BYTES))?;
187
188        anyhow_assert_ge!(bytes.remaining(), 1, "Missing version");
189        let version = bytes.get_u8();
190        if 1 != version {
191            anyhow::bail!("Invalid buffer: unknown version");
192        }
193
194        anyhow_assert_ge!(bytes.remaining(), HASH_BYTES, "Missing encrypted hashes");
195        let header_encrypted_hash = Hash::from_slice(&bytes.split_to(HASH_BYTES))?;
196        anyhow_assert_ge!(bytes.remaining(), HASH_BYTES, "Missing encrypted hashes");
197        let post_encrypted_hash = Hash::from_slice(&bytes.split_to(HASH_BYTES))?;
198
199        anyhow_assert_ge!(bytes.remaining(), size_of::<u32>(), "Missing encrypted lengths");
200        let header_encrypted_length = bytes.get_u32() as usize;
201        anyhow_assert_ge!(bytes.remaining(), size_of::<u32>(), "Missing encrypted lengths");
202        let post_encrypted_length = bytes.get_u32() as usize;
203
204        anyhow_assert_ge!(bytes.remaining(), header_encrypted_length, "Missing encrypted header");
205        let header_encrypted = bytes.split_to(header_encrypted_length);
206
207        let post_encrypted = match expect_body {
208            true => {
209                anyhow_assert_ge!(bytes.remaining(), post_encrypted_length, "Missing encrypted post");
210                bytes.split_to(post_encrypted_length)
211            }
212            false => Bytes::new(),
213        };
214
215        anyhow_assert_eq!(bytes.remaining(), 0, "Unexpected remaining data");
216
217        // Recreate the header
218        // log::trace!("Decrypting header length {}", header_encrypted.len());
219        let header_compressed = encryption::decrypt(&header_encrypted, password)?;
220        // log::trace!("Decompressing header length {}", header_compressed.len());
221        let header_bytes = compression::decompress(&header_compressed)?.to_bytes();
222        // log::trace!("Done header length {}", header_bytes.len());
223        let header = json::bytes_to_struct::<EncodedPostHeaderV1>(&header_bytes)?;
224
225        // Verify the hashes
226        anyhow_assert_eq!(header_encrypted_hash, hashing::hash(&header_encrypted));
227        if expect_body {
228            anyhow_assert_eq!(post_encrypted_hash, hashing::hash(&post_encrypted));
229        }
230
231        // Verify the signature
232        {
233            let hash = hashing::hash_multiple(&[header_encrypted_hash.as_ref(), post_encrypted_hash.as_ref()]);
234
235            // Direct
236            if header.signature_mechanism.direct.is_some() {
237                let verification_key = VerificationKey::from_bytes(&header.verification_key_bytes)?;
238                signing::verify(&verification_key, &signature, hash.as_ref())?;
239            }
240            // Ephemeral
241            else if header.signature_mechanism.ephemeral.is_some() {
242                anyhow::bail!("Signature verification ephemeral not implemented")
243            }
244            // Delegation
245            else if header.signature_mechanism.delegation.is_some() {
246                anyhow::bail!("Signature verification delegation not implemented")
247            }
248            // None!
249            else {
250                anyhow::bail!("No signature verification mechanisms")
251            }
252        }
253
254        // Verify the post_id
255        {
256            anyhow_assert_eq!(post_id, Id::from_hash(hashing::hash(signature.as_ref()))?, "post_id is not the has of signature");
257        }
258
259        // Recreate the body
260        let post = match expect_body && decode_body {
261            true => {
262                // log::trace!("Decrypting post length {}", post_encrypted.len());
263                let post_compressed = encryption::decrypt(&post_encrypted, password)?;
264                // log::trace!("Decompressing post length {}", post_compressed.len());
265                let post_bytes = compression::decompress(&post_compressed)?.to_bytes();
266                // log::trace!("Done post length {}", post_bytes.len());
267                let post = std::str::from_utf8(&post_bytes)?;
268                if post.len() != header.post_length {
269                    anyhow::bail!("Post length mismatch: header.post_length={}, actual={}", header.post_length, post.len())
270                }
271                post.to_string()
272            }
273            false => String::new(),
274        };
275
276        // Woohoo!
277        Ok(Self { post_id, signature, header, post })
278    }
279
280    /// Checks that this (already signature-verified) post could legitimately have been admitted into `bucket_location`.
281    /// These mirror the rules a server applies when it accepts a post submission, so anyone holding a bundle can re-check them:
282    /// - the post timestamp falls inside the bucket,
283    /// - the bucket's base_id is one of the post's linked_base_ids,
284    /// - only the author may post into their own User bucket,
285    /// - the post is not newer than `reference_time_millis` (the time at which the holder claims to have had it), allowing for clock drift.
286    ///
287    /// The ReplyToPost / Sequel referenced-header checks are not included as they require the referenced post's header, which is not carried alongside the post.
288    pub fn check_admissible_to_bucket(&self, bucket_location: &BucketLocation, reference_time_millis: TimeMillis) -> anyhow::Result<()> {
289        let post_bucket_time_millis = BucketLocation::round_down_to_bucket_start(self.header.time_millis, bucket_location.duration);
290        if post_bucket_time_millis != bucket_location.bucket_time_millis {
291            anyhow::bail!("post {} timestamp {} does not fall in bucket starting {}", self.post_id, self.header.time_millis, bucket_location.bucket_time_millis);
292        }
293
294        if !self.header.linked_base_ids.contains(&bucket_location.base_id) {
295            anyhow::bail!("post {} is not linked to base_id {}", self.post_id, bucket_location.base_id);
296        }
297
298        if bucket_location.bucket_type == BucketType::User && self.header.client_id()?.id != bucket_location.base_id {
299            anyhow::bail!("post {} in User bucket {} was not authored by that user", self.post_id, bucket_location.base_id);
300        }
301
302        if self.header.time_millis > reference_time_millis + config::CLIENT_POST_TIMESTAMP_DELTA_THRESHOLD {
303            anyhow::bail!("post {} timestamp {} is too far after reference time {}", self.post_id, self.header.time_millis, reference_time_millis);
304        }
305
306        Ok(())
307    }
308}
309
310pub struct EncodedPostBytesV1 {
311    length_without_body: usize,
312    bytes: Bytes,
313}
314
315impl EncodedPostBytesV1 {
316    pub fn bytes_without_body(&self) -> &[u8] {
317        &self.bytes[..self.length_without_body]
318    }
319    pub fn bytes(&self) -> &[u8] {
320        self.bytes.as_ref()
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::client::key_locker::key_locker::{KeyLocker, KeyLockerManager};
328    use crate::client::key_locker::mem_key_locker::MemKeyLockerManager;
329    use crate::tools::time::{MILLIS_IN_MONTH, MILLIS_IN_SECOND, MILLIS_IN_YEAR};
330    use crate::tools::time_provider::time_provider::{RealTimeProvider, TimeProvider};
331
332    #[tokio::test]
333    async fn test_post_v1_verification() -> anyhow::Result<()> {
334        let key_locker_manager = MemKeyLockerManager::new().await?;
335        let key_locker: Arc<dyn KeyLocker> = key_locker_manager.create("this is a random keyphrase".to_string()).await?;
336        let time_provider = RealTimeProvider;
337        let client_id = key_locker.client_id();
338        let timestamp = time_provider.current_time_millis();
339        let linked_base_ids = vec![client_id.id, Id::random(), Id::random(), Id::random()];
340
341        let password1 = linked_base_ids[0];
342        let password2 = linked_base_ids[1];
343
344        let mut encoded_post = EncodedPostV1::new(client_id, timestamp, linked_base_ids, "this is a test post");
345        let bytes = encoded_post.encode_to_bytes_direct(&key_locker).await?;
346
347        // Test decoding with multiple passwords
348        {
349            {
350                let decoded_post = EncodedPostV1::decode_from_bytes(Bytes::copy_from_slice(bytes.bytes()), &password1, true, true)?;
351                assert_eq!(encoded_post, decoded_post);
352            }
353
354            {
355                let decoded_post = EncodedPostV1::decode_from_bytes(Bytes::copy_from_slice(bytes.bytes()), &password2, true, true)?;
356                assert_eq!(encoded_post, decoded_post);
357            }
358        }
359
360        // Test decoding without body
361        {
362            let decoded_post = EncodedPostV1::decode_from_bytes(Bytes::copy_from_slice(bytes.bytes()), &password2, true, false)?;
363            assert_eq!("", decoded_post.post);
364        }
365
366        // Incorrect password
367        {
368            let wrong_password = Id::random();
369            let decoded_post_attempt = EncodedPostV1::decode_from_bytes(Bytes::copy_from_slice(bytes.bytes()), &wrong_password, true, true);
370            if decoded_post_attempt.is_ok() {
371                anyhow::bail!("Decoding with wrong password should fail")
372            }
373        }
374
375        // Tampering with bytes
376        {
377            let mut tampered_bytes = Vec::from(bytes.bytes());
378            tampered_bytes[100] ^= 0xff;
379            let decoded_post_attempt = EncodedPostV1::decode_from_bytes(Bytes::copy_from_slice(&tampered_bytes), &password1, true, true);
380            if decoded_post_attempt.is_ok() {
381                anyhow::bail!("Decoding tampered bytes should fail")
382            }
383        }
384
385        Ok(())
386    }
387
388    #[tokio::test]
389    async fn test_header_only_verification() -> anyhow::Result<()> {
390        let key_locker_manager = MemKeyLockerManager::new().await?;
391        let key_locker: Arc<dyn KeyLocker> = key_locker_manager.create("this is a random keyphrase".to_string()).await?;
392        let time_provider = RealTimeProvider;
393        let client_id = key_locker.client_id();
394        let timestamp = time_provider.current_time_millis();
395        let linked_base_ids = vec![client_id.id, Id::random(), Id::random(), Id::random()];
396
397        let password1 = linked_base_ids[0];
398        let password2 = linked_base_ids[1];
399
400        let mut encoded_post = EncodedPostV1::new(client_id, timestamp, linked_base_ids, "this is a test post");
401        let bytes = encoded_post.encode_to_bytes_direct(&key_locker).await?;
402
403        {
404            let decoded_post = EncodedPostV1::decode_from_bytes(Bytes::copy_from_slice(bytes.bytes_without_body()), &password1, false, false)?;
405            assert_eq!(encoded_post.signature, decoded_post.signature);
406            assert_eq!(encoded_post.header, decoded_post.header);
407            assert_eq!(String::new(), decoded_post.post);
408        }
409
410        {
411            let decoded_post = EncodedPostV1::decode_from_bytes(Bytes::copy_from_slice(bytes.bytes_without_body()), &password2, false, false)?;
412            assert_eq!(encoded_post.signature, decoded_post.signature);
413            assert_eq!(encoded_post.header, decoded_post.header);
414            assert_eq!(String::new(), decoded_post.post);
415        }
416
417        // Provide the body when not expected
418        {
419            let decoded_post_attempt = EncodedPostV1::decode_from_bytes(Bytes::copy_from_slice(bytes.bytes()), &password2, false, false);
420            if decoded_post_attempt.is_ok() {
421                anyhow::bail!("Decoding with wrong too many bytes should fail")
422            }
423        }
424
425        {
426            let wrong_password = Id::random();
427            let decoded_post_attempt = EncodedPostV1::decode_from_bytes(Bytes::copy_from_slice(bytes.bytes_without_body()), &wrong_password, false, false);
428            if decoded_post_attempt.is_ok() {
429                anyhow::bail!("Decoding with wrong password should fail")
430            }
431        }
432
433        {
434            let mut tampered_bytes = Vec::from(bytes.bytes_without_body());
435            tampered_bytes[100] ^= 0xff;
436            let decoded_post_attempt = EncodedPostV1::decode_from_bytes(Bytes::copy_from_slice(&tampered_bytes), &password1, false, false);
437            if decoded_post_attempt.is_ok() {
438                anyhow::bail!("Decoding tampered bytes should fail")
439            }
440        }
441
442        Ok(())
443    }
444
445    #[tokio::test]
446    async fn test_post_with_no_linked_base_ids() -> anyhow::Result<()> {
447        let key_locker_manager = MemKeyLockerManager::new().await?;
448        let key_locker: Arc<dyn KeyLocker> = key_locker_manager.create("this is a random keyphrase".to_string()).await?;
449        let time_provider = RealTimeProvider;
450        let client_id = key_locker.client_id();
451        let timestamp = time_provider.current_time_millis();
452
453        // No linked_base_ids — only the client_id itself is used as password
454        let password = client_id.id;
455        let mut encoded_post = EncodedPostV1::new(client_id, timestamp, vec![], "post with no linked ids");
456        let bytes = encoded_post.encode_to_bytes_direct(&key_locker).await?;
457
458        // Decrypt with the client_id password succeeds
459        let decoded_post = EncodedPostV1::decode_from_bytes(Bytes::copy_from_slice(bytes.bytes()), &password, true, true)?;
460        assert_eq!(encoded_post, decoded_post);
461
462        // Wrong password fails
463        let wrong_password = Id::random();
464        let attempt = EncodedPostV1::decode_from_bytes(Bytes::copy_from_slice(bytes.bytes()), &wrong_password, true, true);
465        if attempt.is_ok() {
466            anyhow::bail!("Decoding with wrong password should fail");
467        }
468
469        Ok(())
470    }
471
472    #[tokio::test]
473    async fn test_bytes_without_body_round_trip() -> anyhow::Result<()> {
474        let key_locker_manager = MemKeyLockerManager::new().await?;
475        let key_locker: Arc<dyn KeyLocker> = key_locker_manager.create("test keyphrase for bytes_without_body".to_string()).await?;
476        let time_provider = RealTimeProvider;
477        let client_id = key_locker.client_id();
478        let timestamp = time_provider.current_time_millis();
479        let linked_base_ids = vec![client_id.id, Id::random(), Id::random()];
480
481        let password = linked_base_ids[0];
482
483        let mut encoded_post = EncodedPostV1::new(client_id, timestamp, linked_base_ids, "test post for bytes_without_body");
484        let bytes = encoded_post.encode_to_bytes_direct(&key_locker).await?;
485
486        // bytes_without_body from the static method should match the EncodedPostBytesV1 method
487        let full_bytes = Bytes::copy_from_slice(bytes.bytes());
488        let header_bytes_static = EncodedPostV1::bytes_without_body(full_bytes.clone())?;
489        let header_bytes_original = Bytes::copy_from_slice(bytes.bytes_without_body());
490        assert_eq!(header_bytes_static, header_bytes_original);
491
492        // The header bytes should be decodable with expect_body=false
493        let decoded_header_only = EncodedPostV1::decode_from_bytes(header_bytes_static.clone(), &password, false, false)?;
494        assert_eq!(encoded_post.header, decoded_header_only.header);
495        assert_eq!(encoded_post.signature, decoded_header_only.signature);
496        assert_eq!(encoded_post.post_id, decoded_header_only.post_id);
497        assert_eq!("", decoded_header_only.post);
498
499        // Hex round-trip (simulating the WASM bridge flow)
500        let hex_encoded = hex::encode(&header_bytes_static);
501        let hex_decoded = Bytes::from(hex::decode(&hex_encoded)?);
502        let decoded_from_hex = EncodedPostV1::decode_from_bytes(hex_decoded, &password, false, false)?;
503        assert_eq!(encoded_post.header, decoded_from_hex.header);
504
505        Ok(())
506    }
507
508    // ── Robustness tests: decode_signature_from_bytes ──
509
510    #[test]
511    fn test_decode_signature_from_bytes_empty() {
512        assert!(EncodedPostV1::decode_signature_from_bytes(&[]).is_err());
513    }
514
515    #[test]
516    fn test_decode_signature_from_bytes_too_short() {
517        assert!(EncodedPostV1::decode_signature_from_bytes(&[0u8; SIGNATURE_BYTES - 1]).is_err());
518    }
519
520    #[test]
521    fn test_decode_signature_from_bytes_exact_length() {
522        // Should succeed (signature is all zeros, but structurally valid)
523        assert!(EncodedPostV1::decode_signature_from_bytes(&[0u8; SIGNATURE_BYTES]).is_ok());
524    }
525
526    // ── Robustness tests: decode_from_bytes ──
527
528    #[test]
529    fn test_decode_from_bytes_empty() {
530        let password = Id::random();
531        assert!(EncodedPostV1::decode_from_bytes(Bytes::new(), &password, true, true).is_err());
532    }
533
534    #[test]
535    fn test_decode_from_bytes_too_short_for_post_id() {
536        let password = Id::random();
537        let bytes = Bytes::from_static(&[0u8; ID_BYTES - 1]);
538        assert!(EncodedPostV1::decode_from_bytes(bytes, &password, true, true).is_err());
539    }
540
541    #[test]
542    fn test_decode_from_bytes_garbage() {
543        let password = Id::random();
544        let bytes = Bytes::from_static(&[0xff; 256]);
545        assert!(EncodedPostV1::decode_from_bytes(bytes, &password, true, true).is_err());
546    }
547
548    // ── Robustness tests: bytes_without_body ──
549
550    #[test]
551    fn test_bytes_without_body_empty() {
552        assert!(EncodedPostV1::bytes_without_body(Bytes::new()).is_err());
553    }
554
555    #[test]
556    fn test_bytes_without_body_too_short() {
557        let bytes = Bytes::from_static(&[0u8; 10]);
558        assert!(EncodedPostV1::bytes_without_body(bytes).is_err());
559    }
560
561    // ── check_admissible_to_bucket ──
562
563    /// Encodes and re-decodes (header only) a post by a fresh client, so that the returned post carries a real post_id.
564    async fn make_decoded_post(timestamp: TimeMillis, linked_base_ids_extra: Vec<Id>) -> anyhow::Result<(ClientId, EncodedPostV1)> {
565        let key_locker_manager = MemKeyLockerManager::new().await?;
566        let key_locker: Arc<dyn KeyLocker> = key_locker_manager.create("admissibility keyphrase".to_string()).await?;
567        let client_id = key_locker.client_id().clone();
568        let mut linked_base_ids = vec![client_id.id];
569        linked_base_ids.extend(linked_base_ids_extra);
570        let mut encoded_post = EncodedPostV1::new(&client_id, timestamp, linked_base_ids, "admissibility test post");
571        let encoded_post_bytes = encoded_post.encode_to_bytes_direct(&key_locker).await?;
572        let decoded_post = EncodedPostV1::decode_from_bytes(Bytes::copy_from_slice(encoded_post_bytes.bytes()), &client_id.id, true, false)?;
573        Ok((client_id, decoded_post))
574    }
575
576    #[tokio::test]
577    async fn test_check_admissible_to_bucket_accepts_matching_post() -> anyhow::Result<()> {
578        let timestamp = RealTimeProvider.current_time_millis();
579        let hashtag_id = Id::random();
580        let (client_id, decoded_post) = make_decoded_post(timestamp, vec![hashtag_id]).await?;
581
582        let user_bucket_location = BucketLocation::new(BucketType::User, client_id.id, MILLIS_IN_MONTH, timestamp)?;
583        decoded_post.check_admissible_to_bucket(&user_bucket_location, timestamp)?;
584
585        let hashtag_bucket_location = BucketLocation::new(BucketType::Hashtag, hashtag_id, MILLIS_IN_MONTH, timestamp)?;
586        decoded_post.check_admissible_to_bucket(&hashtag_bucket_location, timestamp)?;
587        Ok(())
588    }
589
590    #[tokio::test]
591    async fn test_check_admissible_to_bucket_rejects_post_from_other_bucket() -> anyhow::Result<()> {
592        let timestamp = RealTimeProvider.current_time_millis();
593        let (client_id, decoded_post) = make_decoded_post(timestamp, vec![]).await?;
594
595        // Same user, but the bucket one month earlier: a replay of a genuine old post into a newer bucket must fail, and vice versa.
596        let earlier_bucket_location = BucketLocation::new(BucketType::User, client_id.id, MILLIS_IN_MONTH, timestamp - MILLIS_IN_MONTH)?;
597        assert!(decoded_post.check_admissible_to_bucket(&earlier_bucket_location, timestamp).is_err());
598        let later_bucket_location = BucketLocation::new(BucketType::User, client_id.id, MILLIS_IN_MONTH, timestamp + MILLIS_IN_MONTH)?;
599        assert!(decoded_post.check_admissible_to_bucket(&later_bucket_location, timestamp + MILLIS_IN_MONTH).is_err());
600        Ok(())
601    }
602
603    #[tokio::test]
604    async fn test_check_admissible_to_bucket_rejects_unlinked_base_id() -> anyhow::Result<()> {
605        let timestamp = RealTimeProvider.current_time_millis();
606        let (_client_id, decoded_post) = make_decoded_post(timestamp, vec![]).await?;
607
608        let hashtag_bucket_location = BucketLocation::new(BucketType::Hashtag, Id::random(), MILLIS_IN_MONTH, timestamp)?;
609        assert!(decoded_post.check_admissible_to_bucket(&hashtag_bucket_location, timestamp).is_err());
610        Ok(())
611    }
612
613    #[tokio::test]
614    async fn test_check_admissible_to_bucket_rejects_foreign_author_in_user_bucket() -> anyhow::Result<()> {
615        let timestamp = RealTimeProvider.current_time_millis();
616        let victim_id = Id::random();
617        // The attacker links the victim's id, so the post decrypts under the victim's base_id - but they are not the author.
618        let (_client_id, decoded_post) = make_decoded_post(timestamp, vec![victim_id]).await?;
619
620        let victim_user_bucket_location = BucketLocation::new(BucketType::User, victim_id, MILLIS_IN_MONTH, timestamp)?;
621        assert!(decoded_post.check_admissible_to_bucket(&victim_user_bucket_location, timestamp).is_err());
622
623        // The same linkage is fine for a Mention bucket
624        let victim_mention_bucket_location = BucketLocation::new(BucketType::Mention, victim_id, MILLIS_IN_MONTH, timestamp)?;
625        decoded_post.check_admissible_to_bucket(&victim_mention_bucket_location, timestamp)?;
626        Ok(())
627    }
628
629    #[tokio::test]
630    async fn test_check_admissible_to_bucket_rejects_post_newer_than_reference() -> anyhow::Result<()> {
631        let timestamp = RealTimeProvider.current_time_millis();
632        let (client_id, decoded_post) = make_decoded_post(timestamp, vec![]).await?;
633        let user_bucket_location = BucketLocation::new(BucketType::User, client_id.id, MILLIS_IN_YEAR, timestamp)?;
634
635        // Within the allowed drift is fine
636        decoded_post.check_admissible_to_bucket(&user_bucket_location, timestamp - config::CLIENT_POST_TIMESTAMP_DELTA_THRESHOLD)?;
637
638        // Beyond it is not
639        let too_early_reference_time_millis = timestamp - config::CLIENT_POST_TIMESTAMP_DELTA_THRESHOLD - MILLIS_IN_SECOND;
640        assert!(decoded_post.check_admissible_to_bucket(&user_bucket_location, too_early_reference_time_millis).is_err());
641        Ok(())
642    }
643
644    #[cfg(not(target_arch = "wasm32"))]
645    mod bolero_fuzz {
646        use crate::protocol::posting::encoded_post::EncodedPostV1;
647        use crate::tools::types::Id;
648        use bytes::Bytes;
649
650        #[test]
651        fn fuzz_decode_from_bytes() {
652            bolero::check!().for_each(|data: &[u8]| {
653                let password = Id::zero();
654                let _ = EncodedPostV1::decode_from_bytes(Bytes::copy_from_slice(data), &password, true, true);
655            });
656        }
657
658        #[test]
659        fn fuzz_decode_signature_from_bytes() {
660            bolero::check!().for_each(|data: &[u8]| {
661                let _ = EncodedPostV1::decode_signature_from_bytes(data);
662            });
663        }
664
665        #[test]
666        fn fuzz_bytes_without_body() {
667            bolero::check!().for_each(|data: &[u8]| {
668                let _ = EncodedPostV1::bytes_without_body(Bytes::copy_from_slice(data));
669            });
670        }
671    }
672}