securedrop_protocol_minimal/api.rs
1//! Client API traits for the SecureDrop protocol.
2//!
3//! This module defines the shared API surface for both source and journalist
4//! clients. The [`Api`] trait provides common operations such as key fetching,
5//! signature verification, and message submission. The [`JournalistApi`] trait
6//! extends [`Api`] with journalist-specific operations like enrollment and
7//! ephemeral key management.
8//!
9//! # Trust model
10//!
11//! Key verification follows a chain of trust:
12//! 1. The FPF signing key is a trust anchor (pre-distributed out of band).
13//! 2. The newsroom's verifying key is signed by FPF.
14//! 3. Each journalist's signing key is signed by the newsroom.
15//! 4. Each journalist's long-term and one-time key bundles are self-signed.
16
17use crate::{
18 Enrollable, Envelope, FetchResponse, JournalistPublicView, UserPublic, UserSecret,
19 VerifyingKey,
20 client::challenge::solve_fetch_challenges,
21 client::message::encrypt,
22 traits::RestrictedApi,
23 wire::bundle::SignedKeyBundlePublic,
24 wire::{
25 core::{
26 JournalistEphemeralKeys, JournalistLongTermView, MessageChallengeFetchRequest,
27 MessageFetchRequest, WelcomeBundle,
28 },
29 setup::{JournalistEphemeralKeyRequest, JournalistSetupRequest},
30 },
31};
32use alloc::vec::Vec;
33use anyhow::Error;
34use rand_core::{CryptoRng, RngCore};
35use uuid::Uuid;
36
37/// Clients hold a reference to the newsroom [`VerifyingKey`](VerifyingKey)
38/// of the instance they are interacting with.
39pub trait Client {
40 /// Returns the stored newsroom verifying key, if one has been verified.
41 fn newsroom_verifying_key(&self) -> Option<&VerifyingKey>;
42
43 /// Stores a verified newsroom verifying key.
44 fn set_newsroom_verifying_key(&mut self, key: VerifyingKey);
45}
46
47/// Common API shared by sources and journalists. [`Api`](Api) users must provide
48/// a Client implementation (local storage abstraction).
49/// All users use the same API, but hax does not support default trait implementations
50/// (cryspen/hax/issues/888) so the trait is defined separately.
51pub trait Api: Client {
52 /// Creates a request to fetch encrypted message IDs from the server.
53 ///
54 /// Corresponds to step 7 in the protocol spec. The server returns a
55 /// fixed-size set of challenges (encrypted message IDs) that the client
56 /// must solve using [`solve_fetch_challenges`](Api::solve_fetch_challenges).
57 fn fetch_message_ids<R: RngCore + CryptoRng>(
58 &self,
59 _rng: &mut R,
60 ) -> MessageChallengeFetchRequest;
61
62 /// Solves the encrypted message-ID challenges returned by the server.
63 ///
64 /// Each [`FetchResponse`] contains an encrypted message ID and a
65 /// per-request DH share. The client uses its fetch keypair to recover
66 /// message IDs that were addressed to it, discarding the rest.
67 ///
68 /// Returns the set of [`Uuid`]s for messages belonging to this client.
69 fn solve_fetch_challenges(&self, challenges: &[FetchResponse]) -> Result<Vec<Uuid>, Error>
70 where
71 Self: Sized + UserSecret;
72
73 /// Creates a request to fetch a specific message by its ID.
74 ///
75 /// Corresponds to steps 8 and 10 in the protocol spec. Returns `None`
76 /// if the request cannot be constructed (the default implementation
77 /// always returns `Some`).
78 fn fetch_message(&self, message_id: Uuid) -> Option<MessageFetchRequest>;
79
80 /// Encrypts and submits a message from `sender` to `recipient`.
81 ///
82 /// Handles padding, plaintext construction (including sender reply keys),
83 /// and hybrid encryption. This covers step 6 (source submissions) and
84 /// step 9 (journalist replies) in the protocol spec.
85 ///
86 /// # Errors
87 ///
88 /// Returns an error if encryption fails.
89 fn submit_message<R, S, P>(
90 &self,
91 rng: &mut R,
92 message: &[u8],
93 sender: &S,
94 recipient: &P,
95 ) -> Result<Envelope, Error>
96 where
97 R: RngCore + CryptoRng,
98 S: UserSecret,
99 P: UserPublic;
100
101 /// Verifies a newsroom [`WelcomeBundle`] and stores the newsroom key (step 5).
102 ///
103 /// We check the FPF signature using the newsroom verifying key, then for every
104 /// journalist in the roster we verify:
105 /// * the newsroom's signature over the journalist's verifying key
106 /// * the journalist's signature over their long term keys
107 ///
108 /// On success the newsroom key is stored, and the long term views can be
109 /// cached and reused.
110 ///
111 /// # Errors
112 ///
113 /// Returns an error if the FPF signature or any journalist signature is invalid.
114 fn handle_welcome(
115 &mut self,
116 welcome: &WelcomeBundle,
117 fpf_verifying_key: &VerifyingKey,
118 ) -> Result<(), Error>;
119
120 /// Verifies one journalist's long-term view against a trusted newsroom verifying key,
121 /// the newsroom's signature over the journalist's verifying key, and the
122 /// journalist's signature over their long term keys.
123 ///
124 /// # Errors
125 ///
126 /// Returns an error if either signature is invalid.
127 fn verify_long_term(
128 &self,
129 journalist: &JournalistLongTermView,
130 newsroom_verifying_key: &VerifyingKey,
131 ) -> Result<(), Error>;
132
133 /// Verifies a list of journalists' one-time bundles against their already verified long-term view and assembles a list of `JournalistPublicView` for encryption. Discards any bundles that do not match a verification key in the roster.
134 ///
135 /// # Errors
136 ///
137 /// Returns an error if the signature on the one-time bundle is invalid, or if the same journalist appears multiple times in the list.
138 fn verify_ephemeral_keys(
139 &self,
140 roster: &[JournalistLongTermView],
141 ephemeral: &[JournalistEphemeralKeys],
142 ) -> Result<Vec<JournalistPublicView>, Error>;
143}
144
145impl<T> Api for T
146where
147 T: Client,
148{
149 /// Creates a request to fetch encrypted message IDs from the server.
150 ///
151 /// Corresponds to step 7 in the protocol spec. The server returns a
152 /// fixed-size set of challenges (encrypted message IDs) that the client
153 /// must solve using [`solve_fetch_challenges`](Api::solve_fetch_challenges).
154 fn fetch_message_ids<R: RngCore + CryptoRng>(
155 &self,
156 _rng: &mut R,
157 ) -> MessageChallengeFetchRequest {
158 MessageChallengeFetchRequest {}
159 }
160
161 /// Solves the encrypted message-ID challenges returned by the server.
162 ///
163 /// Each [`FetchResponse`] contains an encrypted message ID and a
164 /// per-request DH share. The client uses its fetch keypair to recover
165 /// message IDs that were addressed to it, discarding the rest.
166 ///
167 /// Returns the set of [`Uuid`]s for messages belonging to this client.
168 fn solve_fetch_challenges(&self, challenges: &[FetchResponse]) -> Result<Vec<Uuid>, Error>
169 where
170 Self: Sized + UserSecret,
171 {
172 Ok(solve_fetch_challenges(self, challenges))
173 }
174
175 /// Creates a request to fetch a specific message by its ID.
176 ///
177 /// Corresponds to steps 8 and 10 in the protocol spec. Returns `None`
178 /// if the request cannot be constructed (the default implementation
179 /// always returns `Some`).
180 fn fetch_message(&self, message_id: Uuid) -> Option<MessageFetchRequest> {
181 Some(MessageFetchRequest { message_id })
182 }
183
184 /// Encrypts and submits a message from `sender` to `recipient`.
185 ///
186 /// Handles padding, plaintext construction (including sender reply keys),
187 /// and hybrid encryption. This covers step 6 (source submissions) and
188 /// step 9 (journalist replies) in the protocol spec.
189 ///
190 /// # Errors
191 ///
192 /// Returns an error if encryption fails.
193 fn submit_message<R, S, P>(
194 &self,
195 rng: &mut R,
196 message: &[u8],
197 sender: &S,
198 recipient: &P,
199 ) -> Result<Envelope, Error>
200 where
201 R: RngCore + CryptoRng,
202 S: UserSecret,
203 P: UserPublic,
204 {
205 // TODO: review padding
206 let padded_message = crate::schemes::pad::pad_message(message);
207 let plaintext = sender.build_message(padded_message);
208 let envelope = encrypt(rng, sender, &plaintext, recipient);
209 Ok(envelope)
210 }
211
212 fn handle_welcome(
213 &mut self,
214 welcome: &WelcomeBundle,
215 fpf_verifying_key: &VerifyingKey,
216 ) -> Result<(), Error> {
217 let newsroom_vk = welcome.newsroom_verifying_key;
218 fpf_verifying_key
219 .verify(&newsroom_vk.into_bytes(), &welcome.fpf_sig)
220 .map_err(|_| anyhow::anyhow!("invalid FPF signature on newsroom verifying key"))?;
221
222 for journalist in welcome.journalists.iter() {
223 self.verify_long_term(journalist, &newsroom_vk)?;
224 }
225
226 self.set_newsroom_verifying_key(newsroom_vk);
227 Ok(())
228 }
229
230 fn verify_long_term(
231 &self,
232 journalist: &JournalistLongTermView,
233 newsroom_verifying_key: &VerifyingKey,
234 ) -> Result<(), Error> {
235 newsroom_verifying_key
236 .verify(&journalist.vk.into_bytes(), &journalist.nr_signature)
237 .map_err(|_| anyhow::anyhow!("invalid newsroom signature on journalist signing key"))?;
238 journalist
239 .vk
240 .verify(
241 &journalist.signed_longterm_key_bundle.bundle_bytes(),
242 &journalist.signed_longterm_key_bundle.selfsig,
243 )
244 .map_err(|_| anyhow::anyhow!("invalid journalist self-signature on long-term keys"))?;
245 Ok(())
246 }
247
248 fn verify_ephemeral_keys(
249 &self,
250 roster: &[JournalistLongTermView],
251 ephemeral: &[JournalistEphemeralKeys],
252 ) -> Result<Vec<JournalistPublicView>, Error> {
253 let mut views = Vec::new();
254 let mut seen: Vec<[u8; 32]> = Vec::new();
255
256 for eph in ephemeral {
257 let vk = eph.vk.into_bytes();
258 let Some(long_term) = roster.iter().find(|j| j.vk.into_bytes() == vk) else {
259 continue; // Discard if no match in roster
260 };
261 if seen.contains(&vk) {
262 return Err(anyhow::anyhow!(
263 "detected multiple ephemeral key bundles for the same journalist signing key"
264 ));
265 }
266 seen.push(vk);
267
268 let signed_bundle = &eph.ephemeral;
269 long_term
270 .vk
271 .verify(&signed_bundle.0.as_bytes(), &signed_bundle.1)
272 .map_err(|_| {
273 anyhow::anyhow!("invalid journalist self-signature on one-time keys")
274 })?;
275
276 views.push(JournalistPublicView::new(
277 long_term.vk,
278 long_term.signed_longterm_key_bundle.clone(),
279 signed_bundle.clone(),
280 ));
281 }
282
283 Ok(views)
284 }
285}
286
287/// Provide generic implementation, restricted to implementors RestrictedApi trait and
288/// the Enrollable trait. Implementors of both those will automatically be able to use
289/// this generic JournalistApi implementation, but downstream crates will be unable to
290/// implement RestrictedApi. Originally this was defined at the trait level
291/// (`pub trait JournalistApi: Api + restricted::RestrictedApi`), but hax was unable
292/// to extract the trait.
293impl<T> JournalistApi for T
294where
295 T: Api + Enrollable + RestrictedApi,
296{
297 fn create_setup_request(&self) -> Result<JournalistSetupRequest, Error> {
298 Ok(JournalistSetupRequest {
299 enrollment: self.enroll(),
300 })
301 }
302
303 fn create_ephemeral_key_request(&self) -> JournalistEphemeralKeyRequest {
304 JournalistEphemeralKeyRequest {
305 verifying_key: self.signing_key().clone(),
306 bundles: self.signed_keybundles(),
307 }
308 }
309}
310
311/// Journalist-specific API operations.
312///
313/// Extends [`Api`] with enrollment and ephemeral key management.
314pub trait JournalistApi {
315 /// Creates an enrollment request for initial journalist onboarding.
316 ///
317 /// Packages the journalist's self-signed long-term key bundle into a
318 /// [`JournalistSetupRequest`] for submission to the newsroom (step 3.1).
319 ///
320 /// # Errors
321 ///
322 /// Returns an error if enrollment data cannot be constructed.
323 fn create_setup_request(&self) -> Result<JournalistSetupRequest, Error>;
324
325 /// Creates a request to replenish ephemeral key bundles on the server.
326 ///
327 /// Collects all current signed key bundles and packages them into a
328 /// [`JournalistEphemeralKeyRequest`] for upload to the server (step 3.2).
329 fn create_ephemeral_key_request(&self) -> JournalistEphemeralKeyRequest;
330}