Skip to main content

core_crypto/proteus/
prekey.rs

1use core_crypto_keystore::{
2    CryptoKeystoreError, Transaction,
3    entities::ProteusPrekey,
4    traits::{Entity, EntityDatabaseMutation as _, FetchFromDatabase as _},
5};
6use proteus_wasm::keys::PreKeyBundle;
7
8use super::ProteusCentral;
9use crate::{KeystoreError, ProteusError, Result};
10
11impl ProteusCentral {
12    /// Generates a new Proteus PreKey, stores it in the keystore and returns a serialized PreKeyBundle to be consumed
13    /// externally
14    ///
15    /// Fails if `id` is already taken. Prekeys are not replaceable: the id has been published to
16    /// peers in a bundle, and overwriting it would strand anyone still holding that bundle.
17    pub(crate) async fn new_prekey(&self, id: u16, transaction: &Transaction) -> Result<Vec<u8>> {
18        use proteus_wasm::keys::{PreKey, PreKeyId};
19
20        // The keystore also refuses a duplicate id, but only once the transaction is applied,
21        // which would take down whatever else that transaction was doing and report the conflict
22        // far from the call responsible for it. Catch it here, while we can still name the id and
23        // leave the transaction usable. Reading through the transaction consults its buffered
24        // operations as well as the database, so an id claimed earlier in this same transaction
25        // counts as taken.
26        if transaction
27            .get::<ProteusPrekey>(&id)
28            .await
29            .map_err(KeystoreError::wrap("checking whether a proteus prekey id is free"))?
30            .is_some()
31        {
32            return Err(
33                KeystoreError::wrap("saving keystore prekey")(CryptoKeystoreError::AlreadyExists(
34                    <ProteusPrekey as Entity>::TABLE_NAME,
35                ))
36                .into(),
37            );
38        }
39
40        let prekey_id = PreKeyId::new(id);
41        let prekey = PreKey::new(prekey_id);
42        let keystore_prekey = core_crypto_keystore::entities::ProteusPrekey::from_raw(
43            id,
44            prekey.serialise().map_err(ProteusError::wrap("serialising prekey"))?,
45        );
46        let bundle = PreKeyBundle::new(self.proteus_identity.as_ref().public_key.clone(), &prekey);
47        let bundle = bundle
48            .serialise()
49            .map_err(ProteusError::wrap("serialising prekey bundle"))?;
50        keystore_prekey
51            .save(transaction)
52            .map_err(KeystoreError::wrap("saving keystore prekey"))?;
53        Ok(bundle)
54    }
55
56    /// Generates a new Proteus Prekey, with an automatically auto-incremented ID.
57    ///
58    /// See [ProteusCentral::new_prekey]
59    pub(crate) async fn new_prekey_auto(&self, transaction: &Transaction) -> Result<(u16, Vec<u8>)> {
60        // the guard over the transaction's connectin is not reentrant, so we have to release
61        // it before `self.new_prekey` tries to acquire it
62        let id = {
63            let conn = transaction
64                .conn()
65                .map_err(KeystoreError::wrap("getting connection from transaction"))?;
66            ProteusPrekey::get_free_id(&conn).map_err(KeystoreError::wrap("getting proteus prekey by id"))?
67        };
68        Ok((id, self.new_prekey(id, transaction).await?))
69    }
70
71    /// Returns the Proteus last resort prekey ID (u16::MAX = 65535 = 0xFFFF)
72    pub fn last_resort_prekey_id() -> u16 {
73        proteus_wasm::keys::MAX_PREKEY_ID.value()
74    }
75
76    /// Returns the Proteus last resort prekey
77    /// If it cannot be found, one will be created.
78    pub(crate) async fn last_resort_prekey(&self, transaction: &Transaction) -> Result<Vec<u8>> {
79        let last_resort = if let Some(last_resort) = transaction
80            .get::<core_crypto_keystore::entities::ProteusPrekey>(&Self::last_resort_prekey_id())
81            .await
82            .map_err(KeystoreError::wrap("finding proteus prekey"))?
83        {
84            proteus_wasm::keys::PreKey::deserialise(&last_resort.prekey)
85                .map_err(ProteusError::wrap("deserialising proteus prekey"))?
86        } else {
87            let last_resort = proteus_wasm::keys::PreKey::last_resort();
88            let prekey = last_resort
89                .serialise()
90                .map_err(ProteusError::wrap("serializing last resort prekey"))?;
91
92            ProteusPrekey::from_raw(Self::last_resort_prekey_id(), prekey)
93                .save(transaction)
94                .map_err(KeystoreError::wrap("storing proteus last resort prekey"))?;
95
96            last_resort
97        };
98
99        let bundle = PreKeyBundle::new(self.proteus_identity.as_ref().public_key.clone(), &last_resort);
100        let bundle = bundle
101            .serialise()
102            .map_err(ProteusError::wrap("serialising prekey bundle"))?;
103
104        Ok(bundle)
105    }
106
107    /// Hex-encoded fingerprint of the given prekey
108    ///
109    /// # Errors
110    /// If the prekey cannot be deserialized
111    pub fn fingerprint_prekeybundle(prekey: &[u8]) -> Result<String> {
112        let prekey = PreKeyBundle::deserialise(prekey).map_err(ProteusError::wrap("deserialising prekey bundle"))?;
113        Ok(prekey.identity_key.fingerprint())
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use core_crypto_keystore::DatabaseKey;
120
121    use super::*;
122    use crate::test_utils::{proteus_utils::*, *};
123
124    #[macro_rules_attribute::apply(smol_macros::test)]
125    async fn can_produce_proteus_consumed_prekeys() {
126        #[cfg(not(target_os = "unknown"))]
127        let (path, db_file) = tmp_db_file();
128        #[cfg(target_os = "unknown")]
129        let (path, _) = tmp_db_file();
130
131        let session_id = uuid::Uuid::new_v4().hyphenated().to_string();
132
133        let key = DatabaseKey::generate();
134        let keystore = core_crypto_keystore::Database::open(&path, &key).await.unwrap();
135        let tx = keystore.new_transaction().await.unwrap();
136        let mut alice = ProteusCentral::try_new(&tx).await.unwrap();
137
138        let mut bob = CryptoboxLike::init();
139
140        let alice_prekey_bundle_ser = alice.new_prekey(1, &tx).await.unwrap();
141
142        bob.init_session_from_prekey_bundle(&session_id, &alice_prekey_bundle_ser);
143        let message = b"Hello world!";
144        let encrypted = bob.encrypt(&session_id, message);
145
146        let (_, decrypted) = alice.session_from_message(&tx, &session_id, &encrypted).await.unwrap();
147
148        assert_eq!(message, decrypted.as_slice());
149
150        let encrypted = alice.encrypt(&tx, &session_id, message).await.unwrap();
151        let decrypted = bob.decrypt(&session_id, &encrypted).await;
152
153        assert_eq!(message, decrypted.as_slice());
154        tx.commit().await.unwrap();
155        #[cfg(not(target_os = "unknown"))]
156        drop(db_file);
157    }
158
159    /// Auto prekeys fill the holes left by deleted prekeys before extending the id space.
160    ///
161    /// The order in which the holes get filled is deliberately not asserted: the keystore tracks
162    /// ids freed within a transaction in an unordered map, so all that is promised is that every
163    /// hole is filled exactly once before any fresh id is handed out.
164    #[macro_rules_attribute::apply(smol_macros::test)]
165    async fn auto_prekeys_fill_holes() {
166        use core_crypto_keystore::entities::ProteusPrekey;
167        const GAP_AMOUNT: usize = 5;
168        /// How far past the end of the filled id space to keep claiming ids
169        const EXTRA_AMOUNT: u16 = 10;
170        const ID_TEST_RANGE: std::ops::RangeInclusive<u16> = 1..=30;
171
172        /// Claim `count` auto prekeys, returning the assigned ids in the order they were handed out.
173        async fn claim(alice: &ProteusCentral, tx: &Transaction, count: usize) -> Vec<u16> {
174            let mut ids = Vec::with_capacity(count);
175            for _ in 0..count {
176                let (pk_id, pkb) = alice.new_prekey_auto(tx).await.unwrap();
177                let prekey = proteus_wasm::keys::PreKeyBundle::deserialise(&pkb).unwrap();
178                assert_eq!(prekey.prekey_id.value(), pk_id, "the bundle must carry the assigned id");
179                ids.push(pk_id);
180            }
181            ids
182        }
183
184        /// Pick `count` distinct ids from `ID_TEST_RANGE`, in ascending order.
185        fn pick_gap_ids(rng: &mut impl rand::Rng, count: usize) -> Vec<u16> {
186            let mut ids = Vec::with_capacity(count);
187            while ids.len() < count {
188                let id = rng.gen_range(ID_TEST_RANGE);
189                if !ids.contains(&id) {
190                    ids.push(id);
191                }
192            }
193            ids.sort();
194            ids
195        }
196
197        fn ascending(ids: &[u16]) -> Vec<u16> {
198            let mut ids = ids.to_owned();
199            ids.sort();
200            ids
201        }
202
203        #[cfg(not(target_os = "unknown"))]
204        let (path, db_file) = tmp_db_file();
205        #[cfg(target_os = "unknown")]
206        let (path, _) = tmp_db_file();
207
208        let key = DatabaseKey::generate();
209        let keystore = core_crypto_keystore::Database::open(&path, &key).await.unwrap();
210        let tx = keystore.new_transaction().await.unwrap();
211        let alice = ProteusCentral::try_new(&tx).await.unwrap();
212
213        // with no holes to fill, ids are simply assigned in ascending order from 1
214        let claimed = claim(&alice, &tx, ID_TEST_RANGE.count()).await;
215        assert_eq!(claimed, ID_TEST_RANGE.collect::<Vec<_>>());
216
217        let mut rng = rand::thread_rng();
218
219        // punch some holes; the next claims must fill exactly those
220        let gap_ids = pick_gap_ids(&mut rng, GAP_AMOUNT);
221        for gap_id in &gap_ids {
222            ProteusPrekey::delete(&*tx, gap_id).unwrap();
223        }
224        let claimed = claim(&alice, &tx, GAP_AMOUNT).await;
225        assert_eq!(
226            ascending(&claimed),
227            gap_ids,
228            "every deleted id must be reassigned exactly once"
229        );
230
231        // punch holes again, but this time keep claiming past them, to check both that holes take
232        // priority over fresh ids and that the fresh ids resume above the filled id space
233        let gap_ids = pick_gap_ids(&mut rng, GAP_AMOUNT);
234        for gap_id in &gap_ids {
235            ProteusPrekey::delete(&*tx, gap_id).unwrap();
236        }
237        let claimed = claim(&alice, &tx, GAP_AMOUNT + EXTRA_AMOUNT as usize).await;
238        let (filled, extended) = claimed.split_at(GAP_AMOUNT);
239        assert_eq!(
240            ascending(filled),
241            gap_ids,
242            "holes must all be filled before the id space is extended"
243        );
244        let high_water_mark = *ID_TEST_RANGE.end();
245        assert_eq!(
246            extended,
247            (high_water_mark + 1..=high_water_mark + EXTRA_AMOUNT).collect::<Vec<_>>(),
248            "once no holes remain, ids extend the id space in ascending order"
249        );
250
251        tx.commit().await.unwrap();
252        #[cfg(not(target_os = "unknown"))]
253        drop(db_file);
254    }
255
256    /// The last resort prekey is stored in the same table as ordinary prekeys, at `u16::MAX`.
257    /// Free-id selection must not treat it as though the id space were exhausted.
258    #[macro_rules_attribute::apply(smol_macros::test)]
259    async fn last_resort_prekey_does_not_exhaust_id_space() {
260        #[cfg(not(target_os = "unknown"))]
261        let (path, db_file) = tmp_db_file();
262        #[cfg(target_os = "unknown")]
263        let (path, _) = tmp_db_file();
264
265        let key = DatabaseKey::generate();
266        let keystore = core_crypto_keystore::Database::open(&path, &key).await.unwrap();
267
268        // in this transaction the last resort prekey is only cached, never persisted
269        let tx = keystore.new_transaction().await.unwrap();
270        let alice = ProteusCentral::try_new(&tx).await.unwrap();
271        alice.last_resort_prekey(&tx).await.unwrap();
272        let (pk_id, _) = alice.new_prekey_auto(&tx).await.unwrap();
273        assert_eq!(
274            pk_id, 1,
275            "an uncommitted last resort prekey must not block auto prekeys"
276        );
277        tx.commit().await.unwrap();
278
279        // now the last resort prekey is persisted, so it's the database query which has to skip it
280        let tx = keystore.new_transaction().await.unwrap();
281        let (pk_id, _) = alice.new_prekey_auto(&tx).await.unwrap();
282        assert_eq!(pk_id, 2, "a persisted last resort prekey must not block auto prekeys");
283        tx.commit().await.unwrap();
284
285        #[cfg(not(target_os = "unknown"))]
286        drop(db_file);
287    }
288
289    /// Claiming a prekey id which is already taken fails, and fails at the call responsible.
290    ///
291    /// The keystore refuses the duplicate on its own, but only when the transaction is applied.
292    /// This exercises the guard in front of it: the caller learns immediately, the prekey already
293    /// stored under that id is left alone, and the transaction survives to commit whatever else
294    /// it was carrying.
295    #[macro_rules_attribute::apply(smol_macros::test)]
296    async fn cannot_reuse_a_prekey_id() {
297        #[cfg(not(target_os = "unknown"))]
298        let (path, db_file) = tmp_db_file();
299        #[cfg(target_os = "unknown")]
300        let (path, _) = tmp_db_file();
301
302        let key = DatabaseKey::generate();
303        let keystore = core_crypto_keystore::Database::open(&path, &key).await.unwrap();
304        let tx = keystore.new_transaction().await.unwrap();
305        let alice = ProteusCentral::try_new(&tx).await.unwrap();
306
307        let bundle = alice.new_prekey(1, &tx).await.unwrap();
308
309        // taken by an id claimed earlier in this same transaction, which is only visible in the
310        // transaction's buffered operations and not yet in the database
311        assert!(
312            alice.new_prekey(1, &tx).await.is_err(),
313            "an id claimed earlier in this transaction must not be reassigned"
314        );
315        tx.commit().await.unwrap();
316
317        // and taken by an id claimed in an earlier transaction, which reaches the database
318        let tx = keystore.new_transaction().await.unwrap();
319        assert!(
320            alice.new_prekey(1, &tx).await.is_err(),
321            "a persisted id must not be reassigned"
322        );
323
324        // the original prekey survived both rejected claims
325        let stored = tx.get::<ProteusPrekey>(&1).await.unwrap().unwrap();
326        let stored = proteus_wasm::keys::PreKey::deserialise(&stored.prekey).unwrap();
327        let bundle = PreKeyBundle::deserialise(&bundle).unwrap();
328        assert_eq!(
329            stored.key_pair.public_key, bundle.public_key,
330            "the rejected claims must have left the original prekey in place"
331        );
332
333        // and the transaction is still usable
334        let (pk_id, _) = alice.new_prekey_auto(&tx).await.unwrap();
335        assert_eq!(pk_id, 2, "a rejected claim must not poison the transaction");
336        tx.commit().await.unwrap();
337
338        #[cfg(not(target_os = "unknown"))]
339        drop(db_file);
340    }
341}