Skip to main content

securedrop_protocol_minimal/
ciphertext.rs

1use crate::message::MessageCiphertext;
2use crate::metadata::MetadataCiphertext;
3use crate::primitives::provider::constants::LEN_KMID;
4use crate::primitives::ristretto255::DHPublicKey;
5use crate::primitives::xwing::XWingPublicKey;
6use alloc::vec::Vec;
7use anyhow::Error;
8#[cfg(not(hax))]
9use serde::{Deserialize, Serialize};
10
11/// Hex string serde for fixed length byte arrays
12#[cfg(not(hax))]
13mod hex_array {
14    use alloc::string::String;
15    use serde::de::Error as _;
16    use serde::{Deserialize, Deserializer, Serializer};
17
18    pub fn serialize<const N: usize, S: Serializer>(
19        bytes: &[u8; N],
20        ser: S,
21    ) -> Result<S::Ok, S::Error> {
22        ser.serialize_str(&hex::encode(bytes))
23    }
24
25    pub fn deserialize<'de, const N: usize, D: Deserializer<'de>>(
26        de: D,
27    ) -> Result<[u8; N], D::Error> {
28        let s = String::deserialize(de)?;
29        let mut out = [0u8; N];
30        hex::decode_to_slice(s.trim(), &mut out).map_err(D::Error::custom)?;
31        Ok(out)
32    }
33}
34
35/// The full submission `(C_S, X, Z)` sent from sender to server in step 6.
36///
37/// - `C_S = (ct^APKE, ct^PKE)`: the two ciphertexts
38/// - `X = g^x`: ephemeral DH public key (hint)
39/// - `Z = (pk_R^fetch)^x`: DH share for fetching (hint)
40///
41/// The server stores `(id, C_S, X, Z)` per message.
42#[derive(Debug, Clone)]
43#[cfg_attr(not(hax), derive(Serialize, Deserialize))]
44pub struct Envelope {
45    /// `ct^APKE`: SD-APKE ciphertext `((c1, cp), c2)` - the encrypted message
46    pub(crate) ct_apke: MessageCiphertext,
47
48    /// `ct^PKE`: SD-PKE ciphertext `(c, c')` - the encrypted sender APKE public key
49    pub(crate) ct_pke: MetadataCiphertext,
50
51    /// `X = g^x`: ephemeral DH public key for the hint
52    pub(crate) mgdh_pubkey: DHPublicKey,
53
54    /// `Z = (pk_R^fetch)^x`: DH share for fetching
55    pub(crate) mgdh: DHPublicKey,
56}
57
58impl Envelope {
59    // Used for benchmarks - see wasm_bindgen
60    pub fn size_hint(&self) -> usize {
61        self.ct_apke.len() + self.ct_pke.len()
62    }
63
64    pub fn cmessage_len(&self) -> usize {
65        self.ct_apke.len()
66    }
67
68    // SD-PKE ciphertext byte length: encapsulation c + AEAD ciphertext c'
69    pub fn cmetadata_len(&self) -> usize {
70        self.ct_pke.len()
71    }
72}
73
74#[derive(Debug, Clone)]
75/// Toy pt structure
76pub struct Plaintext {
77    /// Fetching key: $pk_S^{fetch}$ in the spec
78    pub sender_fetch_key: DHPublicKey,
79    /// Metadata key: $pk_S^{PKE}$ in the spec
80    pub sender_reply_pubkey_hybrid: [u8; XWingPublicKey::LEN],
81    /// Message
82    pub msg: Vec<u8>,
83}
84
85impl Plaintext {
86    pub fn to_bytes(&self) -> alloc::vec::Vec<u8> {
87        let mut buf = Vec::new();
88
89        buf.extend_from_slice(&self.sender_fetch_key.into_bytes());
90        buf.extend_from_slice(&self.sender_reply_pubkey_hybrid);
91        buf.extend_from_slice(&self.msg);
92
93        buf
94    }
95
96    pub fn len(&self) -> usize {
97        DHPublicKey::LEN + XWingPublicKey::LEN + self.msg.len()
98    }
99
100    // Toy parsing only
101    pub fn from_bytes(pt_bytes: &[u8]) -> Result<Self, Error> {
102        let mut offset = 0;
103
104        let mut fetch_key_bytes = [0u8; DHPublicKey::LEN];
105        fetch_key_bytes.copy_from_slice(&pt_bytes[offset..offset + DHPublicKey::LEN]);
106
107        let sender_fetch_key = DHPublicKey::decode(fetch_key_bytes)?;
108        offset += DHPublicKey::LEN;
109
110        let mut sender_reply_pubkey_hybrid = [0u8; XWingPublicKey::LEN];
111        sender_reply_pubkey_hybrid.copy_from_slice(&pt_bytes[offset..offset + XWingPublicKey::LEN]);
112        offset += XWingPublicKey::LEN;
113
114        let msg = pt_bytes[offset..].to_vec();
115
116        Ok(Plaintext {
117            sender_fetch_key,
118            sender_reply_pubkey_hybrid,
119            msg,
120        })
121    }
122}
123
124#[derive(Clone, Debug)]
125#[cfg_attr(not(hax), derive(Serialize, Deserialize))]
126pub struct FetchResponse {
127    #[cfg_attr(not(hax), serde(with = "hex_array"))]
128    pub(crate) enc_id: [u8; LEN_KMID], // aka kmid
129    // `Q_k`, the per-request clue
130    pub(crate) pmgdh: DHPublicKey,
131}
132
133impl FetchResponse {
134    pub fn new(enc_id: [u8; LEN_KMID], pmgdh: DHPublicKey) -> Self {
135        Self { enc_id, pmgdh }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::metadata::LEN_METADATA_CIPHERTEXT;
143    use crate::primitives::dh_akem::DH_AKEM_ENCAPS_SECRET_LEN;
144    use crate::primitives::mlkem::LEN_MLKEM_SHAREDSECRET_ENCAPS;
145    use crate::primitives::ristretto255::random_dh_public_key;
146    use crate::primitives::xwing::LEN_XWING_SHAREDSECRET_ENCAPS;
147    use proptest::prelude::*;
148    use rand_chacha::ChaCha20Rng;
149    use rand_core::SeedableRng;
150
151    fn message_ct(c1: Vec<u8>, cp: Vec<u8>, c2: Vec<u8>) -> MessageCiphertext {
152        MessageCiphertext {
153            c1: c1.try_into().expect("c1 length"),
154            cp,
155            c2: c2.try_into().expect("c2 length"),
156        }
157    }
158
159    proptest! {
160        #[test]
161        fn test_message_ciphertext_byte_roundtrip(
162            c1 in prop::collection::vec(any::<u8>(), DH_AKEM_ENCAPS_SECRET_LEN),
163            cp in prop::collection::vec(any::<u8>(), 0..128),
164            c2 in prop::collection::vec(any::<u8>(), LEN_MLKEM_SHAREDSECRET_ENCAPS),
165        ) {
166            let ct = message_ct(c1, cp, c2);
167            let restored = MessageCiphertext::from_bytes(&ct.as_bytes()).expect("valid bytes");
168            prop_assert_eq!(ct.as_bytes(), restored.as_bytes());
169        }
170
171        #[test]
172        fn test_metadata_ciphertext_byte_roundtrip(
173            c in prop::collection::vec(any::<u8>(), LEN_XWING_SHAREDSECRET_ENCAPS),
174            cp in prop::collection::vec(any::<u8>(), LEN_METADATA_CIPHERTEXT)
175            .prop_map(|v| v.try_into().unwrap()),
176        ) {
177            let ct = MetadataCiphertext { c: c.try_into().expect("c length"), cp };
178            let restored = MetadataCiphertext::from_bytes(&ct.as_bytes()).expect("valid bytes");
179            prop_assert_eq!(ct.as_bytes(), restored.as_bytes());
180        }
181
182        #[test]
183        fn test_envelope_serde_roundtrip(
184            c1 in prop::collection::vec(any::<u8>(), DH_AKEM_ENCAPS_SECRET_LEN),
185            cp_a in prop::collection::vec(any::<u8>(), 0..128),
186            c2 in prop::collection::vec(any::<u8>(), LEN_MLKEM_SHAREDSECRET_ENCAPS),
187            c in prop::collection::vec(any::<u8>(), LEN_XWING_SHAREDSECRET_ENCAPS),
188            cp_b in prop::collection::vec(any::<u8>(), LEN_METADATA_CIPHERTEXT)
189            .prop_map(|v| v.try_into().unwrap()),
190            rng_seed: u64,
191        ) {
192            // X and Z must be valid ristretto255 encodings
193            let mut rng = ChaCha20Rng::seed_from_u64(rng_seed);
194            let mgdh_pubkey = random_dh_public_key(&mut rng);
195            let mgdh = random_dh_public_key(&mut rng);
196
197            let env = Envelope {
198                ct_apke: message_ct(c1, cp_a, c2),
199                ct_pke: MetadataCiphertext { c: c.try_into().expect("c length"), cp: cp_b },
200                mgdh_pubkey,
201                mgdh,
202            };
203            let json = serde_json::to_string(&env).expect("serialize");
204            let restored: Envelope = serde_json::from_str(&json).expect("deserialize");
205            prop_assert_eq!(env.ct_apke.as_bytes(), restored.ct_apke.as_bytes());
206            prop_assert_eq!(env.ct_pke.as_bytes(), restored.ct_pke.as_bytes());
207            prop_assert_eq!(env.mgdh_pubkey.into_bytes(), restored.mgdh_pubkey.into_bytes());
208            prop_assert_eq!(env.mgdh.into_bytes(), restored.mgdh.into_bytes());
209        }
210
211        #[test]
212        fn test_fetch_challenge_response_serde_roundtrip(
213            enc_ids in prop::collection::vec(
214                prop::collection::vec(any::<u8>(), LEN_KMID), 0..6),
215            rng_seed: u64,
216        ) {
217            // pmgdh is a ristretto255 group element
218            let mut rng = ChaCha20Rng::seed_from_u64(rng_seed);
219            let messages: Vec<FetchResponse> = enc_ids
220                .iter()
221                .map(|enc_id| FetchResponse {
222                    enc_id: enc_id.clone().try_into().expect("enc_id length"),
223                    pmgdh: random_dh_public_key(&mut rng),
224                })
225                .collect();
226            let n = messages.len();
227            let resp = crate::wire::core::MessageChallengeFetchResponse { count: n, messages };
228
229            let json = serde_json::to_string(&resp).expect("serialize");
230            let restored: crate::wire::core::MessageChallengeFetchResponse =
231                serde_json::from_str(&json).expect("deserialize");
232
233            prop_assert_eq!(restored.count, resp.count);
234            prop_assert_eq!(restored.messages.len(), resp.messages.len());
235            for (a, b) in resp.messages.iter().zip(restored.messages.iter()) {
236                prop_assert_eq!(a.enc_id, b.enc_id);
237                prop_assert_eq!(a.pmgdh.into_bytes(), b.pmgdh.into_bytes());
238            }
239        }
240    }
241}