nym_wasm_storage/
lib.rs

1// Copyright 2023-2025 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::cipher_export::StoredExportedStoreCipher;
5use crate::error::StorageError;
6use indexed_db_futures::transaction::TransactionMode;
7use nym_store_cipher::{
8    Aes256Gcm, Algorithm, EncryptedData, KdfInfo, KeySizeUser, Params, StoreCipher, Unsigned,
9    Version,
10};
11use nym_wasm_utils::console_log;
12use serde::de::DeserializeOwned;
13use serde::Serialize;
14use std::future::IntoFuture;
15use wasm_bindgen::JsValue;
16
17pub use indexed_db_futures::database::{Database, VersionChangeEvent};
18pub use indexed_db_futures::prelude::*;
19pub use indexed_db_futures::primitive::{TryFromJs, TryToJs};
20pub use indexed_db_futures::Result as RawDbResult;
21
22mod cipher_export;
23pub mod error;
24pub mod traits;
25
26pub const CIPHER_INFO_STORE: &str = "_cipher_store";
27pub const CIPHER_STORE_EXPORT: &str = "cipher_store_export_info";
28
29const MEMORY_COST: u32 = 19 * 1024;
30const ITERATIONS: u32 = 2;
31const PARALLELISM: u32 = 1;
32const OUTPUT_LENGTH: usize = <Aes256Gcm as KeySizeUser>::KeySize::USIZE;
33
34// use hardcoded values in case any `Default` implementation changes in the future
35pub fn new_default_kdf() -> Result<KdfInfo, StorageError> {
36    let kdf_salt = KdfInfo::random_salt()?;
37    let kdf_info = KdfInfo::Argon2 {
38        params: Params::new(MEMORY_COST, ITERATIONS, PARALLELISM, Some(OUTPUT_LENGTH)).unwrap(),
39        algorithm: Algorithm::Argon2id,
40        version: Version::V0x13,
41        kdf_salt,
42    };
43    Ok(kdf_info)
44}
45
46/// An indexeddb-backed in-browser storage with optional encryption.
47pub struct WasmStorage {
48    inner: IdbWrapper,
49    // TODO: this might have to be put behind an Arc.
50    store_cipher: Option<StoreCipher>,
51}
52
53impl WasmStorage {
54    pub async fn new<F>(
55        db_name: &str,
56        version: u32,
57        migrate_fn: Option<F>,
58        passphrase: Option<&[u8]>,
59    ) -> Result<Self, StorageError>
60    where
61        F: Fn(VersionChangeEvent, Database) -> RawDbResult<()> + 'static,
62    {
63        // we must always ensure the cipher table is present
64        let db = Database::open(db_name)
65            .with_version(version)
66            .with_on_upgrade_needed(move |event, db| {
67                // Even if the web-sys bindings expose the version as a f64, the IndexedDB API
68                // works with an unsigned integer.
69                // See <https://github.com/rustwasm/wasm-bindgen/issues/1149>
70                let old_version = event.old_version() as u32;
71
72                if old_version < 1 {
73                    db.create_object_store(CIPHER_INFO_STORE).build()?;
74                }
75
76                if let Some(migrate) = migrate_fn.as_ref() {
77                    migrate(event, db)
78                } else {
79                    Ok(())
80                }
81            })
82            .await?;
83
84        let inner = IdbWrapper(db);
85        let store_cipher = inner.setup_store_cipher(passphrase).await?;
86
87        Ok(WasmStorage {
88            inner,
89            store_cipher,
90        })
91    }
92
93    pub async fn delete(self) -> Result<(), StorageError> {
94        self.inner.0.delete()?.into_future().await?;
95        Ok(())
96    }
97
98    pub async fn remove(db_name: &str) -> Result<(), StorageError> {
99        Database::delete_by_name(db_name)?.into_future().await?;
100        Ok(())
101    }
102
103    pub async fn exists(db_name: &str) -> Result<bool, StorageError> {
104        let db = Database::open(db_name).await?;
105
106        // if the db was already created before, at the very least cipher info store should exist,
107        // thus the iterator should return at least one value
108        let some_stores_exist = db.object_store_names().next().is_some();
109
110        // that's super annoying - we have to do cleanup because opening db creates it
111        // (if it didn't exist before)
112        if !some_stores_exist {
113            db.delete()?.into_future().await?
114        }
115
116        Ok(some_stores_exist)
117    }
118
119    pub fn serialize_value<T: Serialize>(&self, value: &T) -> Result<JsValue, StorageError> {
120        if let Some(cipher) = &self.store_cipher {
121            let encrypted = cipher.encrypt_json_value(value)?;
122            Ok(serde_wasm_bindgen::to_value(&encrypted)?)
123        } else {
124            Ok(serde_wasm_bindgen::to_value(&value)?)
125        }
126    }
127
128    pub fn deserialize_value<T: DeserializeOwned>(
129        &self,
130        value: JsValue,
131    ) -> Result<T, StorageError> {
132        if let Some(cipher) = &self.store_cipher {
133            let encrypted: EncryptedData = serde_wasm_bindgen::from_value(value)?;
134            Ok(cipher.decrypt_json_value(encrypted)?)
135        } else {
136            Ok(serde_wasm_bindgen::from_value(value)?)
137        }
138    }
139
140    pub async fn read_value<T, K>(&self, store: &str, key: K) -> Result<Option<T>, StorageError>
141    where
142        T: DeserializeOwned,
143        K: TryToJs,
144    {
145        self.inner
146            .read_value_raw(store, key)
147            .await?
148            .map(|raw| self.deserialize_value(raw))
149            .transpose()
150    }
151
152    pub async fn store_value<T, K>(
153        &self,
154        store: &str,
155        key: K,
156        value: &T,
157    ) -> Result<(), StorageError>
158    where
159        T: Serialize,
160        K: TryToJs + TryFromJs,
161    {
162        self.inner
163            .store_value_raw(store, key, &self.serialize_value(&value)?)
164            .await
165    }
166
167    pub async fn remove_value<K>(&self, store: &str, key: K) -> Result<(), StorageError>
168    where
169        K: TryToJs,
170    {
171        self.inner.remove_value_raw(store, key).await
172    }
173
174    pub async fn has_value<K>(&self, store: &str, key: K) -> Result<bool, StorageError>
175    where
176        K: TryToJs,
177    {
178        match self.key_count(store, key).await? {
179            0 => Ok(false),
180            1 => Ok(true),
181            n => Err(StorageError::DuplicateKey { count: n }),
182        }
183    }
184
185    pub async fn key_count<K>(&self, store: &str, key: K) -> Result<u32, StorageError>
186    where
187        K: TryToJs,
188    {
189        self.inner.get_key_count(store, key).await
190    }
191
192    pub async fn get_all_keys(&self, store: &str) -> Result<Vec<JsValue>, StorageError> {
193        self.inner.get_all_keys(store).await
194    }
195}
196
197struct IdbWrapper(Database);
198
199impl IdbWrapper {
200    async fn read_value_raw<K>(&self, store: &str, key: K) -> Result<Option<JsValue>, StorageError>
201    where
202        K: TryToJs,
203    {
204        self.0
205            .transaction(store)
206            .with_mode(TransactionMode::Readonly)
207            .build()?
208            .object_store(store)?
209            .get(&key)
210            .primitive()?
211            .await
212            .map_err(Into::into)
213    }
214
215    async fn store_value_raw<K, T>(
216        &self,
217        store: &str,
218        key: K,
219        value: &T,
220    ) -> Result<(), StorageError>
221    where
222        K: TryToJs + TryFromJs,
223        T: TryToJs,
224    {
225        let tx = self
226            .0
227            .transaction(store)
228            .with_mode(TransactionMode::Readwrite)
229            .build()?;
230
231        let store = tx.object_store(store)?;
232        store.put(value).with_key(key).primitive()?.await?;
233
234        tx.commit().await.map_err(Into::into)
235    }
236
237    async fn remove_value_raw<K>(&self, store: &str, key: K) -> Result<(), StorageError>
238    where
239        K: TryToJs,
240    {
241        let tx = self
242            .0
243            .transaction(store)
244            .with_mode(TransactionMode::Readwrite)
245            .build()?;
246
247        let store = tx.object_store(store)?;
248        store.delete(key).primitive()?.await?;
249
250        tx.commit().await.map_err(Into::into)
251    }
252
253    async fn get_key_count<K>(&self, store: &str, key: K) -> Result<u32, StorageError>
254    where
255        K: TryToJs,
256    {
257        self.0
258            .transaction(store)
259            .with_mode(TransactionMode::Readonly)
260            .build()?
261            .object_store(store)?
262            .count()
263            .with_query(key)
264            .primitive()?
265            .await
266            .map_err(Into::into)
267    }
268
269    async fn get_all_keys(&self, store: &str) -> Result<Vec<JsValue>, StorageError> {
270        self.0
271            .transaction(store)
272            .with_mode(TransactionMode::Readonly)
273            .build()?
274            .object_store(store)?
275            .get_all_keys()
276            .primitive()?
277            .await?
278            .collect::<Result<Vec<_>, _>>()
279            .map_err(Into::into)
280    }
281
282    async fn read_exported_cipher_store(
283        &self,
284    ) -> Result<Option<StoredExportedStoreCipher>, StorageError> {
285        self.read_value_raw(CIPHER_INFO_STORE, JsValue::from_str(CIPHER_STORE_EXPORT))
286            .await?
287            .map(serde_wasm_bindgen::from_value)
288            .transpose()
289            .map_err(Into::into)
290    }
291
292    async fn store_exported_cipher_store(
293        &self,
294        exported_store_cipher: StoredExportedStoreCipher,
295    ) -> Result<(), StorageError> {
296        self.store_value_raw(
297            CIPHER_INFO_STORE,
298            JsValue::from_str(CIPHER_STORE_EXPORT),
299            &serde_wasm_bindgen::to_value(&exported_store_cipher)?,
300        )
301        .await
302    }
303
304    async fn setup_new_store_cipher(
305        &self,
306        passphrase: Option<&[u8]>,
307    ) -> Result<Option<StoreCipher>, StorageError> {
308        if let Some(passphrase) = passphrase {
309            console_log!("attempting to derive new encryption key");
310            let kdf_info = new_default_kdf()?;
311            let store_cipher = StoreCipher::<Aes256Gcm>::new(passphrase, kdf_info)?;
312            let exported = store_cipher.export_aes256gcm()?;
313            self.store_exported_cipher_store(Some(exported).into())
314                .await?;
315
316            Ok(Some(store_cipher))
317        } else {
318            console_log!("this new storage will not use any encryption");
319            self.store_exported_cipher_store(StoredExportedStoreCipher::NoEncryption)
320                .await?;
321            Ok(None)
322        }
323    }
324
325    async fn restore_existing_cipher(
326        &self,
327        existing: StoredExportedStoreCipher,
328        passphrase: Option<&[u8]>,
329    ) -> Result<Option<StoreCipher>, StorageError> {
330        if let Some(passphrase) = passphrase {
331            console_log!("attempting to use previously derived encryption key");
332            if let StoredExportedStoreCipher::Cipher(exported_cipher) = existing {
333                Ok(Some(StoreCipher::import_aes256gcm(
334                    passphrase,
335                    exported_cipher,
336                )?))
337            } else {
338                Err(StorageError::UnexpectedPassphraseProvided)
339            }
340        } else {
341            console_log!("attempting to restore old unencrypted data");
342            if existing.uses_encryption() {
343                Err(StorageError::NoPassphraseProvided)
344            } else {
345                Ok(None)
346            }
347        }
348    }
349
350    async fn setup_store_cipher(
351        &self,
352        passphrase: Option<&[u8]>,
353    ) -> Result<Option<StoreCipher>, StorageError> {
354        // we have few options of proceeding from here:
355        // no passphrase + no existing info => it's a fresh client that won't use encryption, so just store that info
356        // no passphrase + existing info => check if the existing info has kdf details, if so, reject
357        // passphrase + no existing info => it's a fresh client that will use encryption, so derive what's required and store it
358        // passphrase + existing info => check if the existing info has kdf details, if so, try to re-derive the key
359
360        if let Some(existing_cipher_info) = self.read_exported_cipher_store().await? {
361            self.restore_existing_cipher(existing_cipher_info, passphrase)
362                .await
363        } else {
364            self.setup_new_store_cipher(passphrase).await
365        }
366    }
367}