Skip to main content

securedrop_protocol_minimal/wire/
core.rs

1use crate::message::MessageCiphertext;
2use crate::message::MessagePublicKey;
3use crate::metadata::MetadataCiphertext;
4use crate::primitives::provider::constants::LEN_KMID;
5use crate::primitives::ristretto255::DHPublicKey;
6use crate::sign::{
7    FpfOnNewsroom, JournalistLongTermKey, NewsroomOnJournalist, Signature, VerifyingKey,
8};
9use crate::wire::bundle::{SignedKeyBundlePublic, SignedLongtermKeyBundle};
10use alloc::vec::Vec;
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14/// A journalist's long-term public key material, as carried in the
15/// [`WelcomeBundle`].
16///
17/// Combined with a one-time [`SignedKeyBundlePublic`] (fetched separately
18/// by an ephemeral key request) to reconstruct a `JournalistPublicView` for
19/// encryption.
20#[cfg_attr(not(hax), derive(Serialize, Deserialize))]
21pub struct JournalistLongTermView {
22    pub vk: VerifyingKey,
23    pub signed_longterm_key_bundle: SignedLongtermKeyBundle,
24    pub nr_signature: Signature<NewsroomOnJournalist>,
25}
26
27/// The newsroom "welcome bundle" (step 5): this is everything a sender needs to
28/// begin - the newsroom verifying key, FPF's signature over it, and the roster
29/// of journalists' long-term keys/signatures.
30#[cfg_attr(not(hax), derive(Serialize, Deserialize))]
31pub struct WelcomeBundle {
32    pub newsroom_verifying_key: VerifyingKey,
33    pub fpf_sig: Signature<FpfOnNewsroom>,
34    pub journalists: Vec<JournalistLongTermView>,
35}
36
37/// One journalist's one-time (ephemeral) key bundle. `vk` identifies which
38/// journalist - the server consumes the bundle when it serves it.
39#[cfg_attr(not(hax), derive(Serialize, Deserialize))]
40pub struct JournalistEphemeralKeys {
41    pub vk: VerifyingKey,
42    pub ephemeral: SignedKeyBundlePublic,
43}
44
45/// User (source or journalist) fetches message IDs
46///
47/// This corresponds to step 7 in the spec.
48pub struct MessageChallengeFetchRequest {}
49
50/// Server returns encrypted message IDs
51///
52/// This corresponds to step 7 in the spec.
53#[cfg_attr(not(hax), derive(Serialize, Deserialize))]
54pub struct MessageChallengeFetchResponse {
55    /// Number of message entries returned
56    /// TODO: constant size array
57    pub count: usize,
58    /// Array of FetchResponses, aka (enc_id, pmgdh) pairs, where encid/cid is encrypted message ID and pmgdh (Q) is the group DH share
59    pub messages: Vec<FetchResponse>,
60}
61
62/// User fetches a specific message by ID
63///
64/// This corresponds to step 8 and 10 in the spec.
65pub struct MessageFetchRequest {
66    /// Message ID to fetch
67    pub message_id: Uuid,
68}
69
70/// Hex string serde for fixed length byte arrays
71#[cfg(not(hax))]
72mod hex_array {
73    use alloc::string::String;
74    use serde::de::Error as _;
75    use serde::{Deserialize, Deserializer, Serializer};
76
77    pub fn serialize<const N: usize, S: Serializer>(
78        bytes: &[u8; N],
79        ser: S,
80    ) -> Result<S::Ok, S::Error> {
81        ser.serialize_str(&hex::encode(bytes))
82    }
83
84    pub fn deserialize<'de, const N: usize, D: Deserializer<'de>>(
85        de: D,
86    ) -> Result<[u8; N], D::Error> {
87        let s = String::deserialize(de)?;
88        let mut out = [0u8; N];
89        hex::decode_to_slice(s.trim(), &mut out).map_err(D::Error::custom)?;
90        Ok(out)
91    }
92}
93
94#[derive(Clone, Debug)]
95#[cfg_attr(not(hax), derive(Serialize, Deserialize))]
96pub struct FetchResponse {
97    #[cfg_attr(not(hax), serde(with = "hex_array"))]
98    pub(crate) enc_id: [u8; LEN_KMID], // aka kmid
99    // `Q_k`, the per-request clue
100    pub(crate) pmgdh: DHPublicKey,
101}
102
103impl FetchResponse {
104    pub fn new(enc_id: [u8; LEN_KMID], pmgdh: DHPublicKey) -> Self {
105        Self { enc_id, pmgdh }
106    }
107}
108
109/// The full submission `(C_S, X, Z)` sent from sender to server in step 6.
110///
111/// - `C_S = (ct^APKE, ct^PKE)`: the two ciphertexts
112/// - `X = g^x`: ephemeral DH public key (hint)
113/// - `Z = (pk_R^fetch)^x`: DH share for fetching (hint)
114///
115/// The server stores `(id, C_S, X, Z)` per message.
116#[derive(Debug, Clone)]
117#[cfg_attr(not(hax), derive(Serialize, Deserialize))]
118pub struct Envelope {
119    /// `ct^APKE`: SD-APKE ciphertext `((c1, cp), c2)` - the encrypted message
120    pub(crate) ct_apke: MessageCiphertext,
121
122    /// `ct^PKE`: SD-PKE ciphertext `(c, c')` - the encrypted sender APKE public key
123    pub(crate) ct_pke: MetadataCiphertext,
124
125    /// `X = g^x`: ephemeral DH public key for the hint
126    pub(crate) mgdh_pubkey: DHPublicKey,
127
128    /// `Z = (pk_R^fetch)^x`: DH share for fetching
129    pub(crate) mgdh: DHPublicKey,
130}
131
132impl Envelope {
133    // Used for benchmarks - see wasm_bindgen
134    // Marked as lax to handle the variable size of cp in MessageCiphertext
135    // TODO(#229): This should be removed when cp has a fixed size
136    #[cfg_attr(hax, hax_lib::fstar::verification_status(lax))]
137    pub fn size_hint(&self) -> usize {
138        self.ct_apke.len() + self.ct_pke.len()
139    }
140
141    pub fn cmessage_len(&self) -> usize {
142        self.ct_apke.len()
143    }
144
145    // SD-PKE ciphertext byte length: encapsulation c + AEAD ciphertext c'
146    pub fn cmetadata_len(&self) -> usize {
147        self.ct_pke.len()
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::metadata::LEN_METADATA_CIPHERTEXT;
155    use crate::primitives::dh_akem::DH_AKEM_ENCAPS_SECRET_LEN;
156    use crate::primitives::mlkem::LEN_MLKEM_SHAREDSECRET_ENCAPS;
157    use crate::primitives::ristretto255::random_dh_public_key;
158    use crate::primitives::xwing::LEN_XWING_SHAREDSECRET_ENCAPS;
159    use proptest::prelude::*;
160    use rand_chacha::ChaCha20Rng;
161    use rand_core::SeedableRng;
162
163    fn message_ct(c1: Vec<u8>, cp: Vec<u8>, c2: Vec<u8>) -> MessageCiphertext {
164        MessageCiphertext {
165            c1: c1.try_into().expect("c1 length"),
166            cp,
167            c2: c2.try_into().expect("c2 length"),
168        }
169    }
170
171    proptest! {
172        #[test]
173        fn test_envelope_serde_roundtrip(
174            c1 in prop::collection::vec(any::<u8>(), DH_AKEM_ENCAPS_SECRET_LEN),
175            cp_a in prop::collection::vec(any::<u8>(), 0..128),
176            c2 in prop::collection::vec(any::<u8>(), LEN_MLKEM_SHAREDSECRET_ENCAPS),
177            c in prop::collection::vec(any::<u8>(), LEN_XWING_SHAREDSECRET_ENCAPS),
178            cp_b in prop::collection::vec(any::<u8>(), LEN_METADATA_CIPHERTEXT)
179            .prop_map(|v| v.try_into().unwrap()),
180            rng_seed: u64,
181        ) {
182            // X and Z must be valid ristretto255 encodings
183            let mut rng = ChaCha20Rng::seed_from_u64(rng_seed);
184            let mgdh_pubkey = random_dh_public_key(&mut rng);
185            let mgdh = random_dh_public_key(&mut rng);
186
187            let env = Envelope {
188                ct_apke: message_ct(c1, cp_a, c2),
189                ct_pke: MetadataCiphertext { c: c.try_into().expect("c length"), cp: cp_b },
190                mgdh_pubkey,
191                mgdh,
192            };
193            let json = serde_json::to_string(&env).expect("serialize");
194            let restored: Envelope = serde_json::from_str(&json).expect("deserialize");
195            prop_assert_eq!(env.ct_apke.as_bytes(), restored.ct_apke.as_bytes());
196            prop_assert_eq!(env.ct_pke.as_bytes(), restored.ct_pke.as_bytes());
197            prop_assert_eq!(env.mgdh_pubkey.into_bytes(), restored.mgdh_pubkey.into_bytes());
198            prop_assert_eq!(env.mgdh.into_bytes(), restored.mgdh.into_bytes());
199        }
200
201        #[test]
202        fn test_fetch_challenge_response_serde_roundtrip(
203            enc_ids in prop::collection::vec(
204                prop::collection::vec(any::<u8>(), LEN_KMID), 0..6),
205            rng_seed: u64,
206        ) {
207            // pmgdh is a ristretto255 group element
208            let mut rng = ChaCha20Rng::seed_from_u64(rng_seed);
209            let messages: Vec<FetchResponse> = enc_ids
210                .iter()
211                .map(|enc_id| FetchResponse {
212                    enc_id: enc_id.clone().try_into().expect("enc_id length"),
213                    pmgdh: random_dh_public_key(&mut rng),
214                })
215                .collect();
216            let n = messages.len();
217            let resp = crate::wire::core::MessageChallengeFetchResponse { count: n, messages };
218
219            let json = serde_json::to_string(&resp).expect("serialize");
220            let restored: crate::wire::core::MessageChallengeFetchResponse =
221                serde_json::from_str(&json).expect("deserialize");
222
223            prop_assert_eq!(restored.count, resp.count);
224            prop_assert_eq!(restored.messages.len(), resp.messages.len());
225            for (a, b) in resp.messages.iter().zip(restored.messages.iter()) {
226                prop_assert_eq!(a.enc_id, b.enc_id);
227                prop_assert_eq!(a.pmgdh.into_bytes(), b.pmgdh.into_bytes());
228            }
229        }
230    }
231}