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:
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);
+ }
+}