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    use rand::RngExt as _;
121
122    use super::*;
123    use crate::test_utils::{proteus_utils::*, *};
124
125    #[macro_rules_attribute::apply(smol_macros::test)]
126    async fn can_produce_proteus_consumed_prekeys() {
127        #[cfg(not(target_os = "unknown"))]
128        let (path, db_file) = tmp_db_file();
129        #[cfg(target_os = "unknown")]
130        let (path, _) = tmp_db_file();
131
132        let session_id = uuid::Uuid::new_v4().hyphenated().to_string();
133
134        let key = DatabaseKey::generate();
135        let keystore = core_crypto_keystore::Database::open(&path, &key).await.unwrap();
136        let tx = keystore.new_transaction().await.unwrap();
137        let mut alice = ProteusCentral::try_new(&tx).await.unwrap();
138
139        let mut bob = CryptoboxLike::init();
140
141        let alice_prekey_bundle_ser = alice.new_prekey(1, &tx).await.unwrap();
142
143        bob.init_session_from_prekey_bundle(&session_id, &alice_prekey_bundle_ser);
144        let message = b"Hello world!";
145        let encrypted = bob.encrypt(&session_id, message);
146
147        let (_, decrypted) = alice.session_from_message(&tx, &session_id, &encrypted).await.unwrap();
148
149        assert_eq!(message, decrypted.as_slice());
150
151        let encrypted = alice.encrypt(&tx, &session_id, message).await.unwrap();
152        let decrypted = bob.decrypt(&session_id, &encrypted).await;
153
154        assert_eq!(message, decrypted.as_slice());
155        tx.commit().await.unwrap();
156        #[cfg(not(target_os = "unknown"))]
157        drop(db_file);
158    }
159
160    /// Auto prekeys fill the holes left by deleted prekeys before extending the id space.
161    ///
162    /// The order in which the holes get filled is deliberately not asserted: the keystore tracks
163    /// ids freed within a transaction in an unordered map, so all that is promised is that every
164    /// hole is filled exactly once before any fresh id is handed out.
165    #[macro_rules_attribute::apply(smol_macros::test)]
166    async fn auto_prekeys_fill_holes() {
167        use core_crypto_keystore::entities::ProteusPrekey;
168        const GAP_AMOUNT: usize = 5;
169        /// How far past the end of the filled id space to keep claiming ids
170        const EXTRA_AMOUNT: u16 = 10;
171        const ID_TEST_RANGE: std::ops::RangeInclusive<u16> = 1..=30;
172
173        /// Claim `count` auto prekeys, returning the assigned ids in the order they were handed out.
174        async fn claim(alice: &ProteusCentral, tx: &Transaction, count: usize) -> Vec<u16> {
175            let mut ids = Vec::with_capacity(count);
176            for _ in 0..count {
177                let (pk_id, pkb) = alice.new_prekey_auto(tx).await.unwrap();
178                let prekey = proteus_wasm::keys::PreKeyBundle::deserialise(&pkb).unwrap();
179                assert_eq!(prekey.prekey_id.value(), pk_id, "the bundle must carry the assigned id");
180                ids.push(pk_id);
181            }
182            ids
183        }
184
185        /// Pick `count` distinct ids from `ID_TEST_RANGE`, in ascending order.
186        fn pick_gap_ids(rng: &mut impl rand::Rng, count: usize) -> Vec<u16> {
187            let mut ids = Vec::with_capacity(count);
188            while ids.len() < count {
189                let id = rng.random_range(ID_TEST_RANGE);
190                if !ids.contains(&id) {
191                    ids.push(id);
192                }
193            }
194            ids.sort();
195            ids
196        }
197
198        fn ascending(ids: &[u16]) -> Vec<u16> {
199            let mut ids = ids.to_owned();
200            ids.sort();
201            ids
202        }
203
204        #[cfg(not(target_os = "unknown"))]
205        let (path, db_file) = tmp_db_file();
206        #[cfg(target_os = "unknown")]
207        let (path, _) = tmp_db_file();
208
209        let key = DatabaseKey::generate();
210        let keystore = core_crypto_keystore::Database::open(&path, &key).await.unwrap();
211        let tx = keystore.new_transaction().await.unwrap();
212        let alice = ProteusCentral::try_new(&tx).await.unwrap();
213
214        // with no holes to fill, ids are simply assigned in ascending order from 1
215        let claimed = claim(&alice, &tx, ID_TEST_RANGE.count()).await;
216        assert_eq!(claimed, ID_TEST_RANGE.collect::<Vec<_>>());
217
218        let mut rng = rand::rng();
219
220        // punch some holes; the next claims must fill exactly those
221        let gap_ids = pick_gap_ids(&mut rng, GAP_AMOUNT);
222        for gap_id in &gap_ids {
223            ProteusPrekey::delete(&*tx, gap_id).unwrap();
224        }
225        let claimed = claim(&alice, &tx, GAP_AMOUNT).await;
226        assert_eq!(
227            ascending(&claimed),
228            gap_ids,
229            "every deleted id must be reassigned exactly once"
230        );
231
232        // punch holes again, but this time keep claiming past them, to check both that holes take
233        // priority over fresh ids and that the fresh ids resume above the filled id space
234        let gap_ids = pick_gap_ids(&mut rng, GAP_AMOUNT);
235        for gap_id in &gap_ids {
236            ProteusPrekey::delete(&*tx, gap_id).unwrap();
237        }
238        let claimed = claim(&alice, &tx, GAP_AMOUNT + EXTRA_AMOUNT as usize).await;
239        let (filled, extended) = claimed.split_at(GAP_AMOUNT);
240        assert_eq!(
241            ascending(filled),
242            gap_ids,
243            "holes must all be filled before the id space is extended"
244        );
245        let high_water_mark = *ID_TEST_RANGE.end();
246        assert_eq!(
247            extended,
248            (high_water_mark + 1..=high_water_mark + EXTRA_AMOUNT).collect::<Vec<_>>(),
249            "once no holes remain, ids extend the id space in ascending order"
250        );
251
252        tx.commit().await.unwrap();
253        #[cfg(not(target_os = "unknown"))]
254        drop(db_file);
255    }
256
257    /// The last resort prekey is stored in the same table as ordinary prekeys, at `u16::MAX`.
258    /// Free-id selection must not treat it as though the id space were exhausted.
259    #[macro_rules_attribute::apply(smol_macros::test)]
260    async fn last_resort_prekey_does_not_exhaust_id_space() {
261        #[cfg(not(target_os = "unknown"))]
262        let (path, db_file) = tmp_db_file();
263        #[cfg(target_os = "unknown")]
264        let (path, _) = tmp_db_file();
265
266        let key = DatabaseKey::generate();
267        let keystore = core_crypto_keystore::Database::open(&path, &key).await.unwrap();
268
269        // in this transaction the last resort prekey is only cached, never persisted
270        let tx = keystore.new_transaction().await.unwrap();
271        let alice = ProteusCentral::try_new(&tx).await.unwrap();
272        alice.last_resort_prekey(&tx).await.unwrap();
273        let (pk_id, _) = alice.new_prekey_auto(&tx).await.unwrap();
274        assert_eq!(
275            pk_id, 1,
276            "an uncommitted last resort prekey must not block auto prekeys"
277        );
278        tx.commit().await.unwrap();
279
280        // now the last resort prekey is persisted, so it's the database query which has to skip it
281        let tx = keystore.new_transaction().await.unwrap();
282        let (pk_id, _) = alice.new_prekey_auto(&tx).await.unwrap();
283        assert_eq!(pk_id, 2, "a persisted last resort prekey must not block auto prekeys");
284        tx.commit().await.unwrap();
285
286        #[cfg(not(target_os = "unknown"))]
287        drop(db_file);
288    }
289
290    /// Claiming a prekey id which is already taken fails, and fails at the call responsible.
291    ///
292    /// The keystore refuses the duplicate on its own, but only when the transaction is applied.
293    /// This exercises the guard in front of it: the caller learns immediately, the prekey already
294    /// stored under that id is left alone, and the transaction survives to commit whatever else
295    /// it was carrying.
296    #[macro_rules_attribute::apply(smol_macros::test)]
297    async fn cannot_reuse_a_prekey_id() {
298        #[cfg(not(target_os = "unknown"))]
299        let (path, db_file) = tmp_db_file();
300        #[cfg(target_os = "unknown")]
301        let (path, _) = tmp_db_file();
302
303        let key = DatabaseKey::generate();
304        let keystore = core_crypto_keystore::Database::open(&path, &key).await.unwrap();
305        let tx = keystore.new_transaction().await.unwrap();
306        let alice = ProteusCentral::try_new(&tx).await.unwrap();
307
308        let bundle = alice.new_prekey(1, &tx).await.unwrap();
309
310        // taken by an id claimed earlier in this same transaction, which is only visible in the
311        // transaction's buffered operations and not yet in the database
312        assert!(
313            alice.new_prekey(1, &tx).await.is_err(),
314            "an id claimed earlier in this transaction must not be reassigned"
315        );
316        tx.commit().await.unwrap();
317
318        // and taken by an id claimed in an earlier transaction, which reaches the database
319        let tx = keystore.new_transaction().await.unwrap();
320        assert!(
321            alice.new_prekey(1, &tx).await.is_err(),
322            "a persisted id must not be reassigned"
323        );
324
325        // the original prekey survived both rejected claims
326        let stored = tx.get::<ProteusPrekey>(&1).await.unwrap().unwrap();
327        let stored = proteus_wasm::keys::PreKey::deserialise(&stored.prekey).unwrap();
328        let bundle = PreKeyBundle::deserialise(&bundle).unwrap();
329        assert_eq!(
330            stored.key_pair.public_key, bundle.public_key,
331            "the rejected claims must have left the original prekey in place"
332        );
333
334        // and the transaction is still usable
335        let (pk_id, _) = alice.new_prekey_auto(&tx).await.unwrap();
336        assert_eq!(pk_id, 2, "a rejected claim must not poison the transaction");
337        tx.commit().await.unwrap();
338
339        #[cfg(not(target_os = "unknown"))]
340        drop(db_file);
341    }
342}