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