app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit c06ae168d15c5197cba2eff0bec33e3b99499aaa
parent 2ccc39500555c57f8f167e52d68ae0b42d8d5f7d
Author: triesap <triesap@radroots.dev>
Date:   Mon, 19 Jan 2026 02:07:08 +0000

app-core: add web keystore implementation

- implement encrypted keystore with legacy key config
- add keystore add/read/remove/keys/reset and backup helpers
- map idb/crypto failures to keystore errors
- add unit test for non-wasm add behavior

Diffstat:
Mcrates/core/src/idb/mod.rs | 2++
Mcrates/core/src/keystore/mod.rs | 2++
Acrates/core/src/keystore/web.rs | 260+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 264 insertions(+), 0 deletions(-)

diff --git a/crates/core/src/idb/mod.rs b/crates/core/src/idb/mod.rs @@ -20,7 +20,9 @@ pub use config::{ IDB_STORE_CRYPTO_REGISTRY, IDB_STORE_DATASTORE, IDB_STORE_KEYSTORE, + IDB_STORE_KEYSTORE_CIPHER, IDB_STORE_KEYSTORE_NOSTR, + IDB_STORE_KEYSTORE_NOSTR_CIPHER, IDB_STORE_TANGLE, RADROOTS_IDB_CONFIGS, RADROOTS_IDB_DATABASE, diff --git a/crates/core/src/keystore/mod.rs b/crates/core/src/keystore/mod.rs @@ -1,5 +1,6 @@ pub mod error; pub mod types; +pub mod web; pub use error::{RadrootsClientKeystoreError, RadrootsClientKeystoreErrorMessage}; pub use types::{ @@ -8,3 +9,4 @@ pub use types::{ RadrootsClientKeystoreResult, RadrootsClientKeystoreValue, }; +pub use web::RadrootsClientWebKeystore; diff --git a/crates/core/src/keystore/web.rs b/crates/core/src/keystore/web.rs @@ -0,0 +1,260 @@ +use async_trait::async_trait; + +use crate::backup::RadrootsClientBackupKeystorePayload; +#[cfg(target_arch = "wasm32")] +use crate::crypto::RadrootsClientCryptoError; +use crate::crypto::RadrootsClientLegacyKeyConfig; +use crate::idb::{IDB_CONFIG_KEYSTORE, IDB_STORE_KEYSTORE_CIPHER, RadrootsClientIdbConfig}; +#[cfg(target_arch = "wasm32")] +use crate::idb::RadrootsClientIdbStoreError; +use crate::idb::{RadrootsClientWebEncryptedStore, RadrootsClientWebEncryptedStoreConfig}; + +use super::{ + RadrootsClientKeystore, + RadrootsClientKeystoreError, + RadrootsClientKeystoreResult, +}; + +const DEFAULT_IV_LENGTH: u32 = 12; + +pub struct RadrootsClientWebKeystore { + config: RadrootsClientIdbConfig, + store_id: String, + #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] + encrypted_store: RadrootsClientWebEncryptedStore, +} + +impl RadrootsClientWebKeystore { + pub fn new(config: Option<RadrootsClientIdbConfig>) -> Self { + let config = config.unwrap_or(IDB_CONFIG_KEYSTORE); + let store_id = format!("keystore:{}:{}", config.database, config.store); + let legacy_store = IDB_STORE_KEYSTORE_CIPHER; + let legacy_key_config = RadrootsClientLegacyKeyConfig { + idb_config: RadrootsClientIdbConfig::new(config.database, legacy_store), + key_name: format!("radroots.keystore.{}.aes-gcm.key", config.store), + iv_length: DEFAULT_IV_LENGTH, + algorithm: "AES-GCM".to_string(), + }; + let encrypted_store = RadrootsClientWebEncryptedStore::new( + RadrootsClientWebEncryptedStoreConfig { + idb_config: config, + store_id: store_id.clone(), + legacy_key: Some(legacy_key_config.clone()), + iv_length: Some(DEFAULT_IV_LENGTH), + crypto_service: None, + }, + ); + Self { + config, + store_id, + encrypted_store, + } + } + + pub fn get_config(&self) -> RadrootsClientIdbConfig { + self.config + } + + pub fn get_store_id(&self) -> &str { + &self.store_id + } + + #[cfg(target_arch = "wasm32")] + async fn store_encrypted(&self, key: &str, bytes: &[u8]) -> RadrootsClientKeystoreResult<()> { + let value = js_sys::Uint8Array::from(bytes); + crate::idb::idb_set( + self.config.database, + self.config.store, + key, + &value.into(), + ) + .await + .map_err(map_idb_error) + } +} + +#[async_trait(?Send)] +impl RadrootsClientKeystore for RadrootsClientWebKeystore { + async fn add(&self, key: &str, value: &str) -> RadrootsClientKeystoreResult<String> { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (key, value); + return Err(RadrootsClientKeystoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + let encrypted = self + .encrypted_store + .encrypt_bytes(value.as_bytes()) + .await + .map_err(map_crypto_error)?; + self.store_encrypted(key, &encrypted).await?; + Ok(key.to_string()) + } + } + + async fn remove(&self, key: &str) -> RadrootsClientKeystoreResult<String> { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = key; + return Err(RadrootsClientKeystoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + crate::idb::idb_del(self.config.database, self.config.store, key) + .await + .map_err(map_idb_error)?; + Ok(key.to_string()) + } + } + + async fn read(&self, key: Option<&str>) -> RadrootsClientKeystoreResult<Option<String>> { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = key; + return Err(RadrootsClientKeystoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + let Some(key) = key else { + return Err(RadrootsClientKeystoreError::MissingKey); + }; + let stored = crate::idb::idb_get(self.config.database, self.config.store, key) + .await + .map_err(map_idb_error)?; + let Some(stored) = stored else { + return Err(RadrootsClientKeystoreError::CorruptData); + }; + let Some(bytes) = crate::idb::idb_value_as_bytes(&stored) else { + return Err(RadrootsClientKeystoreError::CorruptData); + }; + let outcome = self + .encrypted_store + .decrypt_record(&bytes) + .await + .map_err(map_crypto_error)?; + if let Some(reencrypted) = outcome.reencrypted { + self.store_encrypted(key, &reencrypted).await?; + } + let plain = + String::from_utf8(outcome.plaintext).map_err(|_| RadrootsClientKeystoreError::CorruptData)?; + Ok(Some(plain)) + } + } + + async fn keys(&self) -> RadrootsClientKeystoreResult<Vec<String>> { + #[cfg(not(target_arch = "wasm32"))] + { + return Err(RadrootsClientKeystoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + crate::idb::idb_keys(self.config.database, self.config.store) + .await + .map_err(map_idb_error) + } + } + + async fn reset(&self) -> RadrootsClientKeystoreResult<()> { + #[cfg(not(target_arch = "wasm32"))] + { + return Err(RadrootsClientKeystoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + crate::idb::idb_clear(self.config.database, self.config.store) + .await + .map_err(map_idb_error)?; + let index = crate::crypto::crypto_registry_get_store_index(&self.store_id) + .await + .map_err(map_crypto_error)?; + if let Some(index) = index { + crate::crypto::crypto_registry_clear_store_index(&self.store_id) + .await + .map_err(map_crypto_error)?; + for key_id in index.key_ids { + crate::crypto::crypto_registry_clear_key_entry(&key_id) + .await + .map_err(map_crypto_error)?; + } + } + Ok(()) + } + } + + fn get_store_id(&self) -> &str { + &self.store_id + } + + async fn export_backup( + &self, + ) -> RadrootsClientKeystoreResult<RadrootsClientBackupKeystorePayload> { + #[cfg(not(target_arch = "wasm32"))] + { + return Err(RadrootsClientKeystoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + let keys = self.keys().await?; + let mut entries = Vec::new(); + for key in keys { + let value = self.read(Some(&key)).await?; + let Some(value) = value else { + return Err(RadrootsClientKeystoreError::CorruptData); + }; + entries.push(crate::backup::RadrootsClientBackupKeystoreEntry { key, value }); + } + Ok(RadrootsClientBackupKeystorePayload { entries }) + } + } + + async fn import_backup( + &self, + payload: RadrootsClientBackupKeystorePayload, + ) -> RadrootsClientKeystoreResult<()> { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = payload; + return Err(RadrootsClientKeystoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + for entry in payload.entries { + self.add(&entry.key, &entry.value).await?; + } + Ok(()) + } + } +} + +#[cfg(target_arch = "wasm32")] +fn map_crypto_error(err: RadrootsClientCryptoError) -> RadrootsClientKeystoreError { + match err { + RadrootsClientCryptoError::IdbUndefined | RadrootsClientCryptoError::CryptoUndefined => { + RadrootsClientKeystoreError::IdbUndefined + } + _ => RadrootsClientKeystoreError::CorruptData, + } +} + +#[cfg(target_arch = "wasm32")] +fn map_idb_error(err: RadrootsClientIdbStoreError) -> RadrootsClientKeystoreError { + match err { + RadrootsClientIdbStoreError::IdbUndefined => RadrootsClientKeystoreError::IdbUndefined, + _ => RadrootsClientKeystoreError::CorruptData, + } +} + +#[cfg(test)] +mod tests { + use super::RadrootsClientWebKeystore; + use crate::keystore::RadrootsClientKeystore; + + #[test] + fn non_wasm_add_errors() { + let store = RadrootsClientWebKeystore::new(None); + let err = futures::executor::block_on(store.add("key", "value")) + .expect_err("idb undefined"); + assert_eq!(err, crate::keystore::RadrootsClientKeystoreError::IdbUndefined); + } +}