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