Skip to main content

securedrop_protocol_minimal/
source.rs

1use crate::VerifyingKey;
2use crate::api::Client;
3use crate::message::{MessagePublicKey, deterministic_keygen as kgen_deterministic_message};
4use crate::metadata::{MetadataPublicKey, deterministic_keygen as kgen_deterministic_metadata};
5use crate::primitives::ristretto255::{DHPrivateKey, DHPublicKey, deterministic_dh_keygen};
6use alloc::string::{String, ToString};
7use alloc::vec::Vec;
8use anyhow::Error;
9use bip39::{Language, Mnemonic};
10use rand_core::{CryptoRng, RngCore};
11
12use crate::ciphertext::Plaintext;
13use crate::keys::*;
14use crate::primitives::provider::hkdf;
15use crate::primitives::ristretto255::DH_SEED_LEN;
16use crate::primitives::xwing::XWING_PUBLIC_KEY_LEN;
17use crate::traits::{UserPublic, UserSecret};
18
19// do not re-export!
20use crate::sealed;
21
22#[cfg(not(hax))]
23impl sealed::Sealed for Source {}
24
25/// Fixed, public, application-specific salt for source key derivation.
26const SOURCE_KDF_SALT: &[u8] = b"securedrop-source-v1";
27
28/// A source and their long-term key material (step 4).
29///
30/// A source's keys are fully determined by their passphrase, a 12-word BIP39
31/// mnemonic. The mnemonic's 16-byte entropy is used directly as the master key
32/// `mk`, from which the fetch key, APKE key, and PKE key are derived with a
33/// domain-separated KDF. Returning sources reconstruct the same keys by calling
34/// [`Source::from_passphrase`] with the same mnemonic.
35pub struct Source {
36    fetch_key: DhFetchKeyPair,
37    message_keys: MessageKeyBundle,
38    passphrase: String,
39    session: SessionStorage,
40}
41
42// hax struggles with the debug format function signature, but it is
43// debug only, so we can exclude it from extraction
44#[cfg_attr(hax, hax_lib::exclude)]
45impl core::fmt::Debug for Source {
46    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
47        // Using non-exhaustive to avoid leaking source keys.
48        f.debug_struct("Source").finish_non_exhaustive()
49    }
50}
51
52/// The public key material of a source, used by journalists to send replies.
53#[derive(Debug, Clone)]
54pub struct SourcePublicView {
55    fetch_pk: DHPublicKey,
56    apke_pk: MessagePublicKey,
57    message_pks: KeyBundlePublic,
58}
59
60impl UserPublic for SourcePublicView {
61    fn fetch_pk(&self) -> &DHPublicKey {
62        &self.fetch_pk
63    }
64
65    fn message_auth_pk(&self) -> &MessagePublicKey {
66        &self.apke_pk
67    }
68
69    fn message_metadata_pk(&self) -> &MetadataPublicKey {
70        &self.message_pks.metadata_pk
71    }
72
73    fn message_enc_pk(&self) -> &MessagePublicKey {
74        &self.message_pks.apke_pk
75    }
76}
77
78impl Client for Source {
79    fn newsroom_verifying_key(&self) -> Option<&VerifyingKey> {
80        self.session.nr_key.as_ref()
81    }
82
83    fn set_newsroom_verifying_key(&mut self, key: VerifyingKey) {
84        self.session.nr_key = Some(key);
85    }
86}
87
88/// Private, common to all users, implemented for sources
89impl UserSecret for Source {
90    fn num_bundles(&self) -> usize {
91        1
92    }
93
94    fn fetch_keypair(&self) -> (&DHPrivateKey, &DHPublicKey) {
95        (&self.fetch_key.sk, &self.fetch_key.pk)
96    }
97
98    fn message_auth_key(&self) -> &crate::message::MessagePrivateKey {
99        self.message_keys.apke.private_key()
100    }
101
102    fn own_message_auth_pk(&self) -> &crate::message::MessagePublicKey {
103        self.message_keys.apke.public_key()
104    }
105
106    fn build_message(&self, message: Vec<u8>) -> Plaintext {
107        let mut reply_key_pq_hybrid = [0u8; XWING_PUBLIC_KEY_LEN];
108        reply_key_pq_hybrid.copy_from_slice(self.message_keys.metadata_kp.public_key().as_bytes());
109
110        Plaintext {
111            sender_fetch_key: self.fetch_key.pk,
112            sender_reply_pubkey_hybrid: reply_key_pq_hybrid,
113            msg: message,
114        }
115    }
116
117    fn keybundles(&self) -> Vec<&MessageKeyBundle> {
118        alloc::vec![&self.message_keys]
119    }
120}
121
122impl Source {
123    /// Create a new source with a randomly generated 12-word BIP39 mnemonic.
124    #[cfg_attr(hax, hax_lib::opaque)]
125    pub fn new<R: RngCore + CryptoRng>(mut rng: R) -> Self {
126        let mut entropy = [0u8; 16];
127        rng.fill_bytes(&mut entropy);
128        let mnemonic = Mnemonic::from_entropy(&entropy).expect("16 bytes is valid BIP39 entropy");
129        Self::from_master_key(&entropy, mnemonic.to_string())
130    }
131
132    /// Returns the source's passphrase as a 12-word BIP39 mnemonic.
133    ///
134    /// # Security
135    ///
136    /// The passphrase is the root secret from which all source keys are
137    /// derived. It MUST be stored and transmitted only over secure channels.
138    #[cfg_attr(hax, hax_lib::opaque)]
139    pub fn passphrase(&self) -> &str {
140        &self.passphrase
141    }
142
143    /// Reconstruct source keys from a 12-word BIP39 mnemonic (step 4).
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if `passphrase` is not a valid 12-word BIP39 mnemonic,
148    /// i.e. it contains an unknown word, has the wrong length, or fails the
149    /// checksum.
150    #[cfg_attr(hax, hax_lib::opaque)]
151    pub fn from_passphrase(passphrase: &str) -> Result<Self, Error> {
152        let mnemonic = Mnemonic::parse_in(Language::English, passphrase)
153            .map_err(|e| anyhow::anyhow!("invalid BIP39 mnemonic: {e}"))?;
154
155        let (entropy, len) = mnemonic.to_entropy_array();
156        if len != 16 {
157            return Err(anyhow::anyhow!(
158                "source passphrase must be a 12-word BIP39 mnemonic (128-bit entropy)"
159            ));
160        }
161        let mut mk = [0u8; 16];
162        mk.copy_from_slice(&entropy[..16]);
163
164        Ok(Self::from_master_key(&mk, mnemonic.to_string()))
165    }
166
167    /// Derive a source's long-term keys from the master key `mk` (the 16-byte
168    /// BIP39 entropy), then assemble the [`Source`] tagged with the originating
169    /// `passphrase` mnemonic.
170    ///
171    /// Each private key is derived from `mk` with a domain-separated KDF.
172    #[cfg_attr(hax, hax_lib::opaque)]
173    fn from_master_key(mk: &[u8; 16], passphrase: String) -> Self {
174        // we need a 64 byte string for the ristretto255 scalar (see RFC 9496 section 4.4).
175        let mut fetch_seed = [0u8; DH_SEED_LEN];
176        hkdf::sha256(&mut fetch_seed, SOURCE_KDF_SALT, mk, b"sourcefetchkey")
177            .expect("HKDF fetch key derivation failed");
178
179        // sk_S^APKE is a hybrid key requiring two sub-derivations:
180        // the DH-AKEM and ML-KEM components are each derived with their own
181        // label under the "sourceAPKEkey" namespace.
182        let mut dh_seed = [0u8; 32];
183        hkdf::sha256(&mut dh_seed, SOURCE_KDF_SALT, mk, b"sourceAPKEkey-dh")
184            .expect("HKDF APKE DH key derivation failed");
185
186        let mut mlkem_seed = [0u8; 64];
187        hkdf::sha256(&mut mlkem_seed, SOURCE_KDF_SALT, mk, b"sourceAPKEkey-mlkem")
188            .expect("HKDF APKE ML-KEM key derivation failed");
189
190        let mut pke_seed = [0u8; 32];
191        hkdf::sha256(&mut pke_seed, SOURCE_KDF_SALT, mk, b"sourcePKEkey")
192            .expect("HKDF PKE key derivation failed");
193
194        // Create key pairs
195        let (fetch_sk, fetch_pk): (DHPrivateKey, DHPublicKey) = deterministic_dh_keygen(fetch_seed);
196
197        let message_kp =
198            kgen_deterministic_message(dh_seed, mlkem_seed).expect("Need SD-APKE keygen");
199
200        let metadata_kp = kgen_deterministic_metadata(pke_seed).expect("Need X-Wing keygen");
201
202        let session = SessionStorage {
203            fpf_key: None,
204            nr_key: None,
205            fpf_signature: None,
206        };
207
208        Self {
209            fetch_key: KeyPair {
210                sk: fetch_sk,
211                pk: fetch_pk,
212            },
213            message_keys: MessageKeyBundle::new(message_kp, metadata_kp),
214            passphrase,
215            session,
216        }
217    }
218
219    /// Returns the public key material for this source.
220    pub fn public(&self) -> SourcePublicView {
221        SourcePublicView {
222            fetch_pk: self.fetch_key.pk,
223            apke_pk: self.message_keys.apke.public_key().clone(),
224            message_pks: self.message_keys.public(),
225        }
226    }
227}
228
229impl SourcePublicView {
230    /// Reconstruct a source's public view from the reply keys recovered when
231    /// decrypting their submission.
232    pub fn from_reply_keys(
233        fetch_pk: DHPublicKey,
234        apke: MessagePublicKey,
235        metadata_pk: MetadataPublicKey,
236    ) -> Self {
237        SourcePublicView {
238            fetch_pk,
239            apke_pk: apke.clone(),
240            message_pks: KeyBundlePublic {
241                apke_pk: apke,
242                metadata_pk,
243            },
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::primitives::xwing::XWING_PRIVATE_KEY_LEN;
252    use proptest::prelude::*;
253    use rand_chacha::ChaCha20Rng;
254    use rand_core::{Rng, SeedableRng};
255
256    /// Canonical BIP39 test vector: 16 zero bytes of entropy.
257    const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
258
259    #[test]
260    fn test_initialize_with_passphrase() {
261        let source1 = Source::from_passphrase(TEST_MNEMONIC).expect("valid mnemonic");
262        let source2 = Source::from_passphrase(TEST_MNEMONIC).expect("valid mnemonic");
263
264        assert_eq!(
265            source1.passphrase, source2.passphrase,
266            "Expected identical passphrase"
267        );
268
269        // SD-APKE keys (pk^APKE = (pk1, pk2))
270        assert_eq!(
271            source1.message_keys.apke.public_key().as_bytes(),
272            source2.message_keys.apke.public_key().as_bytes(),
273            "SD-APKE public key should be identical"
274        );
275
276        // Metadata keys
277        assert_eq!(
278            source1.message_keys.metadata_kp.public_key().as_bytes(),
279            source2.message_keys.metadata_kp.public_key().as_bytes(),
280            "XWING Encaps Key should be identical"
281        );
282        assert_eq!(
283            source1.message_keys.metadata_kp.private_key().as_bytes(),
284            source2.message_keys.metadata_kp.private_key().as_bytes(),
285            "XWING Decaps Key should be identical"
286        );
287        assert_ne!(
288            source1.message_keys.metadata_kp.private_key().as_bytes(),
289            &[0u8; XWING_PRIVATE_KEY_LEN]
290        );
291    }
292
293    proptest! {
294        #[test]
295        fn test_new_source_roundtrips_through_passphrase(seed in any::<u64>()) {
296            let source = Source::new(ChaCha20Rng::seed_from_u64(seed));
297
298            let restored = Source::from_passphrase(source.passphrase())
299                .expect("generated mnemonic is valid");
300
301            prop_assert_eq!(source.passphrase(), restored.passphrase());
302            prop_assert_eq!(
303                source.message_keys.apke.public_key().as_bytes(),
304                restored.message_keys.apke.public_key().as_bytes(),
305            );
306        }
307    }
308
309    #[test]
310    fn test_invalid_mnemonic_is_rejected() {
311        let bad_checksum = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon";
312        assert!(Source::from_passphrase(bad_checksum).is_err());
313
314        assert!(Source::from_passphrase("hello world").is_err());
315    }
316}