diff --git a/Cargo.lock b/Cargo.lock index d8a172c29..8a26c9b97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -362,6 +362,7 @@ dependencies = [ "bitwarden-api-identity", "bitwarden-crypto", "bitwarden-error", + "bitwarden-state", "bitwarden-uuid", "chrono", "getrandom 0.2.16", @@ -599,6 +600,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "bitwarden-state" +version = "1.0.0" +dependencies = [ + "async-trait", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "bitwarden-threading" version = "1.0.0" @@ -633,6 +643,7 @@ dependencies = [ "bitwarden-generators", "bitwarden-send", "bitwarden-ssh", + "bitwarden-state", "bitwarden-vault", "chrono", "env_logger", @@ -675,6 +686,7 @@ dependencies = [ "bitwarden-core", "bitwarden-crypto", "bitwarden-error", + "bitwarden-state", "chrono", "data-encoding", "hmac", @@ -697,6 +709,7 @@ dependencies = [ name = "bitwarden-wasm-internal" version = "0.1.0" dependencies = [ + "async-trait", "base64", "bitwarden-core", "bitwarden-crypto", @@ -705,10 +718,14 @@ dependencies = [ "bitwarden-generators", "bitwarden-ipc", "bitwarden-ssh", + "bitwarden-state", + "bitwarden-threading", "bitwarden-vault", "console_error_panic_hook", "console_log", "log", + "serde", + "tsify-next", "wasm-bindgen", "wasm-bindgen-futures", ] diff --git a/Cargo.toml b/Cargo.toml index bbda3dd09..0527b30d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ bitwarden-fido = { path = "crates/bitwarden-fido", version = "=1.0.0" } bitwarden-generators = { path = "crates/bitwarden-generators", version = "=1.0.0" } bitwarden-ipc = { path = "crates/bitwarden-ipc", version = "=1.0.0" } bitwarden-send = { path = "crates/bitwarden-send", version = "=1.0.0" } +bitwarden-state = { path = "crates/bitwarden-state", version = "=1.0.0" } bitwarden-threading = { path = "crates/bitwarden-threading", version = "=1.0.0" } bitwarden-sm = { path = "bitwarden_license/bitwarden-sm", version = "=1.0.0" } bitwarden-ssh = { path = "crates/bitwarden-ssh", version = "=1.0.0" } @@ -39,6 +40,7 @@ bitwarden-uuid-macro = { path = "crates/bitwarden-uuid-macro", version = "=1.0.0 bitwarden-vault = { path = "crates/bitwarden-vault", version = "=1.0.0" } # External crates that are expected to maintain a consistent version across all crates +async-trait = ">=0.1.80, <0.2" chrono = { version = ">=0.4.26, <0.5", features = [ "clock", "serde", diff --git a/crates/bitwarden-core/Cargo.toml b/crates/bitwarden-core/Cargo.toml index db4f16e4b..2997fa609 100644 --- a/crates/bitwarden-core/Cargo.toml +++ b/crates/bitwarden-core/Cargo.toml @@ -34,6 +34,7 @@ bitwarden-api-api = { workspace = true } bitwarden-api-identity = { workspace = true } bitwarden-crypto = { workspace = true } bitwarden-error = { workspace = true } +bitwarden-state = { workspace = true } bitwarden-uuid = { workspace = true } chrono = { workspace = true, features = ["std"] } # We don't use this directly (it's used by rand), but we need it here to enable WASM support diff --git a/crates/bitwarden-core/src/client/client.rs b/crates/bitwarden-core/src/client/client.rs index 7fcec2ace..ac1042a53 100644 --- a/crates/bitwarden-core/src/client/client.rs +++ b/crates/bitwarden-core/src/client/client.rs @@ -1,6 +1,8 @@ use std::sync::{Arc, OnceLock, RwLock}; use bitwarden_crypto::KeyStore; +#[cfg(feature = "internal")] +use bitwarden_state::registry::StateRegistry; use reqwest::header::{self, HeaderValue}; use super::internal::InternalClient; @@ -88,6 +90,8 @@ impl Client { })), external_client, key_store: KeyStore::default(), + #[cfg(feature = "internal")] + repository_map: StateRegistry::new(), }), } } diff --git a/crates/bitwarden-core/src/client/internal.rs b/crates/bitwarden-core/src/client/internal.rs index ba3f9d0bb..0d39cfa18 100644 --- a/crates/bitwarden-core/src/client/internal.rs +++ b/crates/bitwarden-core/src/client/internal.rs @@ -5,6 +5,8 @@ use bitwarden_crypto::KeyStore; use bitwarden_crypto::SymmetricCryptoKey; #[cfg(feature = "internal")] use bitwarden_crypto::{EncString, Kdf, MasterKey, PinKey, UnsignedSharedKey}; +#[cfg(feature = "internal")] +use bitwarden_state::registry::StateRegistry; use chrono::Utc; use uuid::Uuid; @@ -62,6 +64,9 @@ pub struct InternalClient { pub(crate) external_client: reqwest::Client, pub(super) key_store: KeyStore, + + #[cfg(feature = "internal")] + pub(crate) repository_map: StateRegistry, } impl InternalClient { diff --git a/crates/bitwarden-core/src/platform/mod.rs b/crates/bitwarden-core/src/platform/mod.rs index 9ed3895c4..f8c0527ad 100644 --- a/crates/bitwarden-core/src/platform/mod.rs +++ b/crates/bitwarden-core/src/platform/mod.rs @@ -6,6 +6,7 @@ mod generate_fingerprint; mod get_user_api_key; mod platform_client; mod secret_verification_request; +mod state_client; pub use generate_fingerprint::{ FingerprintError, FingerprintRequest, FingerprintResponse, UserFingerprintError, @@ -14,3 +15,4 @@ pub(crate) use get_user_api_key::get_user_api_key; pub use get_user_api_key::{UserApiKeyError, UserApiKeyResponse}; pub use platform_client::PlatformClient; pub use secret_verification_request::SecretVerificationRequest; +pub use state_client::StateClient; diff --git a/crates/bitwarden-core/src/platform/platform_client.rs b/crates/bitwarden-core/src/platform/platform_client.rs index 3a4fbe380..140252c19 100644 --- a/crates/bitwarden-core/src/platform/platform_client.rs +++ b/crates/bitwarden-core/src/platform/platform_client.rs @@ -33,6 +33,13 @@ impl PlatformClient { ) -> Result { get_user_api_key(&self.client, &input).await } + + /// Access to state functionality. + pub fn state(&self) -> super::StateClient { + super::StateClient { + client: self.client.clone(), + } + } } impl Client { diff --git a/crates/bitwarden-core/src/platform/state_client.rs b/crates/bitwarden-core/src/platform/state_client.rs new file mode 100644 index 000000000..566b9dea7 --- /dev/null +++ b/crates/bitwarden-core/src/platform/state_client.rs @@ -0,0 +1,28 @@ +use std::sync::Arc; + +use bitwarden_state::repository::{Repository, RepositoryItem}; + +use crate::Client; + +/// Wrapper for state specific functionality. +pub struct StateClient { + pub(crate) client: Client, +} + +impl StateClient { + /// Register a client managed state repository for a specific type. + pub fn register_client_managed, V: RepositoryItem>( + &self, + store: Arc, + ) { + self.client + .internal + .repository_map + .register_client_managed(store) + } + + /// Get a client managed state repository for a specific type, if it exists. + pub fn get_client_managed(&self) -> Option>> { + self.client.internal.repository_map.get_client_managed() + } +} diff --git a/crates/bitwarden-fido/Cargo.toml b/crates/bitwarden-fido/Cargo.toml index dd72819b7..0f880a945 100644 --- a/crates/bitwarden-fido/Cargo.toml +++ b/crates/bitwarden-fido/Cargo.toml @@ -18,7 +18,7 @@ keywords.workspace = true uniffi = ["dep:uniffi", "bitwarden-core/uniffi", "bitwarden-vault/uniffi"] [dependencies] -async-trait = ">=0.1.80, <0.2" +async-trait = { workspace = true } base64 = ">=0.22.1, <0.23" bitwarden-core = { workspace = true } bitwarden-crypto = { workspace = true } diff --git a/crates/bitwarden-state/Cargo.toml b/crates/bitwarden-state/Cargo.toml new file mode 100644 index 000000000..0593aad1f --- /dev/null +++ b/crates/bitwarden-state/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "bitwarden-state" +version.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +homepage.workspace = true +repository.workspace = true +license-file.workspace = true +keywords.workspace = true + +[features] +uniffi = [] +wasm = [] + +[dependencies] +async-trait = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt"] } + +[lints] +workspace = true diff --git a/crates/bitwarden-state/README.md b/crates/bitwarden-state/README.md new file mode 100644 index 000000000..cc135e002 --- /dev/null +++ b/crates/bitwarden-state/README.md @@ -0,0 +1,175 @@ +# bitwarden-state + +This crate contains the core state handling code of the Bitwarden SDK. Its primary feature is a +namespaced key-value store, accessible via the typed [Repository](crate::repository::Repository) +trait. + +To make use of the `Repository` trait, the first thing to do is to ensure the data to be used with +it is registered to do so: + +```rust +struct Cipher { + // Cipher fields +}; + +// Register `Cipher` for use with a `Repository`. +// This should be done in the crate where `Cipher` is defined. +bitwarden_state::register_repository_item!(Cipher, "Cipher"); +``` + +With the registration complete, the next important decision is to select where will the data be +stored: + +- If the application using the SDK is responsible for storing the data, it must provide its own + implementation of the `Repository` trait. We call this approach `Client-Managed State` or + `Application-Managed State`. See the next section for details on how to implement this. + +- If the SDK itself will handle data storage, we call that approach `SDK-Managed State`. The + implementation of this is will a work in progress. + +## Client-Managed State + +With `Client-Managed State` the application and SDK will both access the same data pool, which +simplifies the initial migration and development. Using this approach requires manual setup, as we +need to define some functions in `bitwarden-wasm-internal` and `bitwarden-uniffi` to allow the +applications to provide their `Repository` implementations. The implementations themselves will be +very simple as we provide macros that take care of the brunt of the work. + +### Client-Managed State in WASM + +For WASM, we need to define a new `Repository` for our type and provide a function that will accept +it. This is done in the file `crates/bitwarden-wasm-internal/src/platform/mod.rs`, you can check the +provided example: + +```rust,ignore +repository::create_wasm_repository!(CipherRepository, Cipher, "Repository"); + +#[wasm_bindgen] +impl StateClient { + pub fn register_cipher_repository(&self, store: CipherRepository) { + let store = store.into_channel_impl(); + self.0.platform().state().register_client_managed(store) + } +} +``` + +#### How to use it on web clients + +Once we have the function defined in `bitwarden-wasm-internal`, we can use it from the web clients. +For that, the first thing we need to do is create a mapper between the client and SDK types. This +mapper will also contain the `UserKeyDefinition` for the `StateProvider` API and should be created +in the folder of the team that owns the model: + +```typescript +export class CipherRecordMapper implements SdkRecordMapper { + userKeyDefinition(): UserKeyDefinition> { + return ENCRYPTED_CIPHERS; + } + + toSdk(value: CipherData): SdkCipher { + return new Cipher(value).toSdkCipher(); + } + + fromSdk(value: SdkCipher): CipherData { + throw new Error("Cipher.fromSdk is not implemented yet"); + } +} +``` + +Once that is done, we should be able to register the mapper in the +`libs/common/src/platform/services/sdk/client-managed-state.ts` file, inside the `initializeState` +function: + +```typescript +export async function initializeState( + userId: UserId, + stateClient: StateClient, + stateProvider: StateProvider, +): Promise { + await stateClient.register_cipher_repository( + new RepositoryRecord(userId, stateProvider, new CipherRecordMapper()), + ); +} +``` + +### Client-Managed State in UniFFI + +For UniFFI, we need to define a new `Repository` for our type and provide a function that will +accept it. This is done in the file `crates/bitwarden-uniffi/src/platform/mod.rs`, you can check the +provided example: + +```rust,ignore +repository::create_uniffi_repository!(CipherRepository, Cipher); + +#[uniffi::export] +impl StateClient { + pub fn register_cipher_repository(&self, store: Arc) { + let store_internal = UniffiRepositoryBridge::new(store); + self.0 + .platform() + .state() + .register_client_managed(store_internal) + } +} +``` + +#### How to use it on iOS + +Once we have the function defined in `bitwarden-uniffi`, we can use it from the iOS application: + +```swift +class CipherStoreImpl: CipherStore { + private var cipherDataStore: CipherDataStore + private var userId: String + + init(cipherDataStore: CipherDataStore, userId: String) { + self.cipherDataStore = cipherDataStore + self.userId = userId + } + + func get(id: String) async -> Cipher? { + return try await cipherDataStore.fetchCipher(withId: id, userId: userId) + } + + func list() async -> [Cipher] { + return try await cipherDataStore.fetchAllCiphers(userId: userId) + } + + func set(id: String, value: Cipher) async { } + + func remove(id: String) async { } +} + +let store = CipherStoreImpl(cipherDataStore: self.cipherDataStore, userId: userId); +try await self.clientService.platform().store().registerCipherStore(store: store); +``` + +### How to use it on Android + +Once we have the function defined in `bitwarden-uniffi`, we can use it from the Android application: + +```kotlin +val vaultDiskSource: VaultDiskSource ; + +class CipherStoreImpl: CipherStore { + override suspend fun get(id: String): Cipher? { + return vaultDiskSource.getCiphers(userId).firstOrNull() + .orEmpty().firstOrNull { it.id == id }?.toEncryptedSdkCipher() + } + + override suspend fun list(): List { + return vaultDiskSource.getCiphers(userId).firstOrNull() + .orEmpty().map { it.toEncryptedSdkCipher() } + } + + override suspend fun set(id: String, value: Cipher) { + TODO("Not yet implemented") + } + + override suspend fun remove(id: String) { + TODO("Not yet implemented") + } +} + +getClient(userId = userId).platform().store().registerCipherStore(CipherStoreImpl()); +``` diff --git a/crates/bitwarden-state/src/lib.rs b/crates/bitwarden-state/src/lib.rs new file mode 100644 index 000000000..825a46e39 --- /dev/null +++ b/crates/bitwarden-state/src/lib.rs @@ -0,0 +1,7 @@ +#![doc = include_str!("../README.md")] + +/// This module provides a generic repository interface for storing and retrieving items. +pub mod repository; + +/// This module provides a registry for managing repositories of different types. +pub mod registry; diff --git a/crates/bitwarden-state/src/registry.rs b/crates/bitwarden-state/src/registry.rs new file mode 100644 index 000000000..47e9fb832 --- /dev/null +++ b/crates/bitwarden-state/src/registry.rs @@ -0,0 +1,129 @@ +use std::{ + any::{Any, TypeId}, + collections::HashMap, + sync::{Arc, RwLock}, +}; + +use crate::repository::{Repository, RepositoryItem}; + +/// A registry that contains repositories for different types of items. +/// These repositories can be either managed by the client or by the SDK itself. +pub struct StateRegistry { + client_managed: RwLock>>, +} + +impl std::fmt::Debug for StateRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StateRegistry").finish() + } +} + +impl StateRegistry { + /// Creates a new empty `StateRegistry`. + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + StateRegistry { + client_managed: RwLock::new(HashMap::new()), + } + } + + /// Registers a client-managed repository into the map, associating it with its type. + pub fn register_client_managed(&self, value: Arc>) { + self.client_managed + .write() + .expect("RwLock should not be poisoned") + .insert(TypeId::of::(), Box::new(value)); + } + + /// Retrieves a client-managed repository from the map given its type. + pub fn get_client_managed(&self) -> Option>> { + self.client_managed + .read() + .expect("RwLock should not be poisoned") + .get(&TypeId::of::()) + .and_then(|boxed| boxed.downcast_ref::>>()) + .map(Arc::clone) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + register_repository_item, + repository::{RepositoryError, RepositoryItem}, + }; + + macro_rules! impl_repository { + ($name:ident, $ty:ty) => { + #[async_trait::async_trait] + impl Repository<$ty> for $name { + async fn get(&self, _key: String) -> Result, RepositoryError> { + Ok(Some(TestItem(self.0.clone()))) + } + async fn list(&self) -> Result, RepositoryError> { + unimplemented!() + } + async fn set(&self, _key: String, _value: $ty) -> Result<(), RepositoryError> { + unimplemented!() + } + async fn remove(&self, _key: String) -> Result<(), RepositoryError> { + unimplemented!() + } + } + }; + } + + #[derive(PartialEq, Eq, Debug)] + struct TestA(usize); + #[derive(PartialEq, Eq, Debug)] + struct TestB(String); + #[derive(PartialEq, Eq, Debug)] + struct TestC(Vec); + #[derive(PartialEq, Eq, Debug)] + struct TestItem(T); + + register_repository_item!(TestItem, "TestItem"); + register_repository_item!(TestItem, "TestItem"); + register_repository_item!(TestItem>, "TestItem>"); + + impl_repository!(TestA, TestItem); + impl_repository!(TestB, TestItem); + impl_repository!(TestC, TestItem>); + + #[tokio::test] + async fn test_repository_map() { + let a = Arc::new(TestA(145832)); + let b = Arc::new(TestB("test".to_string())); + let c = Arc::new(TestC(vec![1, 2, 3, 4, 5, 6, 7, 8, 9])); + + let map = StateRegistry::new(); + + async fn get(map: &StateRegistry) -> Option { + map.get_client_managed::() + .unwrap() + .get(String::new()) + .await + .unwrap() + } + + assert!(map.get_client_managed::>().is_none()); + assert!(map.get_client_managed::>().is_none()); + assert!(map.get_client_managed::>>().is_none()); + + map.register_client_managed(a.clone()); + assert_eq!(get(&map).await, Some(TestItem(a.0))); + assert!(map.get_client_managed::>().is_none()); + assert!(map.get_client_managed::>>().is_none()); + + map.register_client_managed(b.clone()); + assert_eq!(get(&map).await, Some(TestItem(a.0))); + assert_eq!(get(&map).await, Some(TestItem(b.0.clone()))); + assert!(map.get_client_managed::>>().is_none()); + + map.register_client_managed(c.clone()); + assert_eq!(get(&map).await, Some(TestItem(a.0))); + assert_eq!(get(&map).await, Some(TestItem(b.0.clone()))); + assert_eq!(get(&map).await, Some(TestItem(c.0.clone()))); + } +} diff --git a/crates/bitwarden-state/src/repository.rs b/crates/bitwarden-state/src/repository.rs new file mode 100644 index 000000000..bf19092b2 --- /dev/null +++ b/crates/bitwarden-state/src/repository.rs @@ -0,0 +1,59 @@ +use std::any::TypeId; + +/// An error resulting from operations on a repository. +#[derive(thiserror::Error, Debug)] +pub enum RepositoryError { + /// An internal unspecified error. + #[error("Internal error: {0}")] + Internal(String), +} + +/// This trait represents a generic repository interface, capable of storing and retrieving +/// items using a key-value API. +#[async_trait::async_trait] +pub trait Repository: Send + Sync { + /// Retrieves an item from the repository by its key. + async fn get(&self, key: String) -> Result, RepositoryError>; + /// Lists all items in the repository. + async fn list(&self) -> Result, RepositoryError>; + /// Sets an item in the repository with the specified key. + async fn set(&self, key: String, value: V) -> Result<(), RepositoryError>; + /// Removes an item from the repository by its key. + async fn remove(&self, key: String) -> Result<(), RepositoryError>; +} + +/// This trait is used to mark types that can be stored in a repository. +/// It should not be implemented manually; instead, users should +/// use the [crate::register_repository_item] macro to register their item types. +pub trait RepositoryItem: Internal + Send + Sync + 'static { + /// The name of the type implementing this trait. + const NAME: &'static str; + /// Returns the `TypeId` of the type implementing this trait. + fn type_id() -> TypeId { + TypeId::of::() + } +} + +/// Register a type for use in a repository. The type must only be registered once in the crate +/// where it's defined. The provided name must be unique and not be changed. +#[macro_export] +macro_rules! register_repository_item { + ($ty:ty, $name:literal) => { + const _: () = { + impl $crate::repository::___internal::Internal for $ty {} + impl $crate::repository::RepositoryItem for $ty { + const NAME: &'static str = $name; + } + }; + }; +} + +/// This code is not meant to be used directly, users of this crate should use the +/// [crate::register_repository_item] macro to register their types. +#[doc(hidden)] +pub mod ___internal { + + // This trait is just to try to discourage users from implementing `RepositoryItem` directly. + pub trait Internal {} +} +pub(crate) use ___internal::Internal; diff --git a/crates/bitwarden-threading/Cargo.toml b/crates/bitwarden-threading/Cargo.toml index 36e39458e..87cce8f8c 100644 --- a/crates/bitwarden-threading/Cargo.toml +++ b/crates/bitwarden-threading/Cargo.toml @@ -35,7 +35,7 @@ gloo-timers = { version = "0.3.0", features = ["futures"], optional = true } wasm-bindgen-futures = { workspace = true, optional = true } [dev-dependencies] -async-trait = "0.1.88" +async-trait = { workspace = true } console_error_panic_hook = "0.1.7" js-sys = { workspace = true } tokio-test = "0.4.4" diff --git a/crates/bitwarden-uniffi/Cargo.toml b/crates/bitwarden-uniffi/Cargo.toml index 899281984..e7780758c 100644 --- a/crates/bitwarden-uniffi/Cargo.toml +++ b/crates/bitwarden-uniffi/Cargo.toml @@ -19,7 +19,7 @@ crate-type = ["lib", "staticlib", "cdylib"] bench = false [dependencies] -async-trait = "0.1.80" +async-trait = { workspace = true } bitwarden-core = { workspace = true, features = ["uniffi"] } bitwarden-crypto = { workspace = true, features = ["uniffi"] } bitwarden-exporters = { workspace = true, features = ["uniffi"] } @@ -27,6 +27,7 @@ bitwarden-fido = { workspace = true, features = ["uniffi"] } bitwarden-generators = { workspace = true, features = ["uniffi"] } bitwarden-send = { workspace = true, features = ["uniffi"] } bitwarden-ssh = { workspace = true, features = ["uniffi"] } +bitwarden-state = { workspace = true, features = ["uniffi"] } bitwarden-vault = { workspace = true, features = ["uniffi"] } chrono = { workspace = true, features = ["std"] } env_logger = "0.11.1" diff --git a/crates/bitwarden-uniffi/src/platform/mod.rs b/crates/bitwarden-uniffi/src/platform/mod.rs index 9b0dc5574..798307967 100644 --- a/crates/bitwarden-uniffi/src/platform/mod.rs +++ b/crates/bitwarden-uniffi/src/platform/mod.rs @@ -1,9 +1,14 @@ -use bitwarden_core::platform::FingerprintRequest; +use std::sync::Arc; + +use bitwarden_core::{platform::FingerprintRequest, Client}; use bitwarden_fido::ClientFido2Ext; +use bitwarden_vault::Cipher; +use repository::UniffiRepositoryBridge; use crate::error::{Error, Result}; mod fido2; +mod repository; #[derive(uniffi::Object)] pub struct PlatformClient(pub(crate) bitwarden_core::Client); @@ -38,4 +43,24 @@ impl PlatformClient { pub fn fido2(&self) -> fido2::ClientFido2 { fido2::ClientFido2(self.0.fido2()) } + + pub fn state(&self) -> StateClient { + StateClient(self.0.clone()) + } +} + +#[derive(uniffi::Object)] +pub struct StateClient(Client); + +repository::create_uniffi_repository!(CipherRepository, Cipher); + +#[uniffi::export] +impl StateClient { + pub fn register_cipher_repository(&self, store: Arc) { + let store_internal = UniffiRepositoryBridge::new(store); + self.0 + .platform() + .state() + .register_client_managed(store_internal) + } } diff --git a/crates/bitwarden-uniffi/src/platform/repository.rs b/crates/bitwarden-uniffi/src/platform/repository.rs new file mode 100644 index 000000000..73036fe75 --- /dev/null +++ b/crates/bitwarden-uniffi/src/platform/repository.rs @@ -0,0 +1,104 @@ +use std::sync::Arc; + +pub struct UniffiRepositoryBridge(pub T); + +impl UniffiRepositoryBridge> { + pub fn new(store: Arc) -> Arc { + Arc::new(UniffiRepositoryBridge(store)) + } +} + +impl std::fmt::Debug for UniffiRepositoryBridge { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +#[derive(uniffi::Error, thiserror::Error, Debug)] +#[uniffi(flat_error)] +pub enum RepositoryError { + #[error("Internal error: {0}")] + Internal(String), +} + +// Need to implement this From<> impl in order to handle unexpected callback errors. See the +// following page in the Uniffi user guide: +// +impl From for RepositoryError { + fn from(e: uniffi::UnexpectedUniFFICallbackError) -> Self { + Self::Internal(e.reason) + } +} + +impl From for bitwarden_state::repository::RepositoryError { + fn from(e: RepositoryError) -> Self { + match e { + RepositoryError::Internal(msg) => Self::Internal(msg), + } + } +} + +/// This macro creates a Uniffi repository trait and its implementation for the +/// [bitwarden_state::repository::Repository] trait +macro_rules! create_uniffi_repository { + ($name:ident, $ty:ty) => { + #[uniffi::export(with_foreign)] + #[async_trait::async_trait] + pub trait $name: Send + Sync { + async fn get( + &self, + id: String, + ) -> Result, $crate::platform::repository::RepositoryError>; + async fn list(&self) + -> Result, $crate::platform::repository::RepositoryError>; + async fn set( + &self, + id: String, + value: $ty, + ) -> Result<(), $crate::platform::repository::RepositoryError>; + async fn remove( + &self, + id: String, + ) -> Result<(), $crate::platform::repository::RepositoryError>; + + async fn has( + &self, + id: String, + ) -> Result { + match self.get(id).await { + Ok(x) => Ok(x.is_some()), + Err(e) => Err(e), + } + } + } + + #[async_trait::async_trait] + impl bitwarden_state::repository::Repository<$ty> + for $crate::platform::repository::UniffiRepositoryBridge> + { + async fn get( + &self, + key: String, + ) -> Result, bitwarden_state::repository::RepositoryError> { + self.0.get(key).await.map_err(Into::into) + } + async fn list(&self) -> Result, bitwarden_state::repository::RepositoryError> { + self.0.list().await.map_err(Into::into) + } + async fn set( + &self, + key: String, + value: $ty, + ) -> Result<(), bitwarden_state::repository::RepositoryError> { + self.0.set(key, value).await.map_err(Into::into) + } + async fn remove( + &self, + key: String, + ) -> Result<(), bitwarden_state::repository::RepositoryError> { + self.0.remove(key).await.map_err(Into::into) + } + } + }; +} +pub(super) use create_uniffi_repository; diff --git a/crates/bitwarden-vault/Cargo.toml b/crates/bitwarden-vault/Cargo.toml index 01d496d55..e06ce1460 100644 --- a/crates/bitwarden-vault/Cargo.toml +++ b/crates/bitwarden-vault/Cargo.toml @@ -32,6 +32,7 @@ bitwarden-api-api = { workspace = true } bitwarden-core = { workspace = true, features = ["internal"] } bitwarden-crypto = { workspace = true } bitwarden-error = { workspace = true } +bitwarden-state = { workspace = true } chrono = { workspace = true } data-encoding = ">=2.0, <3" hmac = ">=0.12.1, <0.13" diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index 0d4c515dd..4c9c1bdb1 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -135,6 +135,8 @@ pub struct Cipher { pub revision_date: DateTime, } +bitwarden_state::register_repository_item!(Cipher, "Cipher"); + #[allow(missing_docs)] #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/crates/bitwarden-wasm-internal/Cargo.toml b/crates/bitwarden-wasm-internal/Cargo.toml index a18845414..db4d673ea 100644 --- a/crates/bitwarden-wasm-internal/Cargo.toml +++ b/crates/bitwarden-wasm-internal/Cargo.toml @@ -16,6 +16,7 @@ keywords.workspace = true crate-type = ["cdylib"] [dependencies] +async-trait = { workspace = true } base64 = ">=0.22.1, <0.23.0" bitwarden-core = { workspace = true, features = ["wasm", "internal"] } bitwarden-crypto = { workspace = true, features = ["wasm"] } @@ -24,10 +25,14 @@ bitwarden-exporters = { workspace = true, features = ["wasm"] } bitwarden-generators = { workspace = true, features = ["wasm"] } bitwarden-ipc = { workspace = true, features = ["wasm"] } bitwarden-ssh = { workspace = true, features = ["wasm"] } +bitwarden-state = { workspace = true, features = ["wasm"] } +bitwarden-threading = { workspace = true } bitwarden-vault = { workspace = true, features = ["wasm"] } console_error_panic_hook = "0.1.7" console_log = { version = "1.0.0", features = ["color"] } log = "0.4.20" +serde = { workspace = true } +tsify-next = { workspace = true } # When upgrading wasm-bindgen, make sure to update the version in the workflows! wasm-bindgen = { version = "=0.2.100", features = ["serde-serialize"] } wasm-bindgen-futures = "0.4.41" diff --git a/crates/bitwarden-wasm-internal/src/client.rs b/crates/bitwarden-wasm-internal/src/client.rs index d30262be2..200ff69ff 100644 --- a/crates/bitwarden-wasm-internal/src/client.rs +++ b/crates/bitwarden-wasm-internal/src/client.rs @@ -8,6 +8,8 @@ use bitwarden_generators::GeneratorClientsExt; use bitwarden_vault::{VaultClient, VaultClientExt}; use wasm_bindgen::prelude::*; +use crate::platform::PlatformClient; + #[allow(missing_docs)] #[wasm_bindgen] pub struct BitwardenClient(pub(crate) Client); @@ -53,6 +55,11 @@ impl BitwardenClient { self.0.vault() } + /// Constructs a specific client for platform-specific functionality + pub fn platform(&self) -> PlatformClient { + PlatformClient::new(self.0.clone()) + } + /// Constructs a specific client for generating passwords and passphrases pub fn generator(&self) -> bitwarden_generators::GeneratorClient { self.0.generator() diff --git a/crates/bitwarden-wasm-internal/src/lib.rs b/crates/bitwarden-wasm-internal/src/lib.rs index ac84eeabf..253ec8ffd 100644 --- a/crates/bitwarden-wasm-internal/src/lib.rs +++ b/crates/bitwarden-wasm-internal/src/lib.rs @@ -3,6 +3,7 @@ mod client; mod custom_types; mod init; +mod platform; mod pure_crypto; mod ssh; diff --git a/crates/bitwarden-wasm-internal/src/platform/mod.rs b/crates/bitwarden-wasm-internal/src/platform/mod.rs new file mode 100644 index 000000000..b2b977659 --- /dev/null +++ b/crates/bitwarden-wasm-internal/src/platform/mod.rs @@ -0,0 +1,40 @@ +use bitwarden_core::Client; +use bitwarden_vault::Cipher; +use wasm_bindgen::prelude::wasm_bindgen; + +mod repository; + +#[wasm_bindgen] +pub struct PlatformClient(Client); + +impl PlatformClient { + pub fn new(client: Client) -> Self { + Self(client) + } +} + +#[wasm_bindgen] +impl PlatformClient { + pub fn state(&self) -> StateClient { + StateClient::new(self.0.clone()) + } +} + +#[wasm_bindgen] +pub struct StateClient(Client); + +impl StateClient { + pub fn new(client: Client) -> Self { + Self(client) + } +} + +repository::create_wasm_repository!(CipherRepository, Cipher, "Repository"); + +#[wasm_bindgen] +impl StateClient { + pub fn register_cipher_repository(&self, store: CipherRepository) { + let store = store.into_channel_impl(); + self.0.platform().state().register_client_managed(store) + } +} diff --git a/crates/bitwarden-wasm-internal/src/platform/repository.rs b/crates/bitwarden-wasm-internal/src/platform/repository.rs new file mode 100644 index 000000000..6e9e80138 --- /dev/null +++ b/crates/bitwarden-wasm-internal/src/platform/repository.rs @@ -0,0 +1,193 @@ +/*! + * To support clients implementing the [Repository] trait in a [::wasm_bindgen] environment, + * we need to deal with an `extern "C"` interface, as that is what [::wasm_bindgen] supports: + * + * This looks something like this: + * + * ```rust,ignore + * #[wasm_bindgen] + * extern "C" { + * pub type CipherRepository; + * + * #[wasm_bindgen(method, catch)] + * async fn get(this: &CipherRepository, id: String) -> Result; + * } + * ``` + * + * As you can see, this has a few limitations: + * - The type must be known at compile time, so we cannot use generics directly, which means we + * can't use the existing [Repository] trait directly. + * - The return type must be [JsValue], so we need to convert our types to and from [JsValue]. + * + * To facilitate this, we provide some utilities: + * - [WasmRepository] trait, which defines the methods as we expect them to come from + * [::wasm_bindgen], using [JsValue]. This is generic and should be implemented for each + * concrete repository we define, but the implementation should be very straightforward. + * - [WasmRepositoryChannel] struct, which wraps a [WasmRepository] in a [ThreadBoundRunner] and + * implements the [Repository] trait. This has a few special considerations: + * - It uses [tsify_next::serde_wasm_bindgen] to convert between [JsValue] and our types, so + * we can use the existing [Repository] trait. + * - It runs the calls in a thread-bound manner, so we can safely call the [WasmRepository] + * methods from any thread. + * - The [create_wasm_repository] macro, defines the [::wasm_bindgen] interface and implements + * the [WasmRepository] trait for you. + */ + +use std::{future::Future, marker::PhantomData, rc::Rc}; + +use bitwarden_state::repository::{Repository, RepositoryError, RepositoryItem}; +use bitwarden_threading::ThreadBoundRunner; +use serde::{de::DeserializeOwned, Serialize}; +use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; + +/// This trait defines the methods that a [::wasm_bindgen] repository must implement. +/// The trait itself exists to provide a generic way of handling the [::wasm_bindgen] interface, +/// which is !Send + !Sync, and only deals with [JsValue]. +pub(crate) trait WasmRepository { + async fn get(&self, id: String) -> Result; + async fn list(&self) -> Result; + async fn set(&self, id: String, value: T) -> Result; + async fn remove(&self, id: String) -> Result; +} + +/// This struct wraps a [WasmRepository] in a [ThreadBoundRunner] to allow it to be used as a +/// [Repository] in a thread-safe manner. It implements the [Repository] trait directly, by +/// converting the values as needed with [tsify_next::serde_wasm_bindgen]. +pub(crate) struct WasmRepositoryChannel + 'static>( + ThreadBoundRunner, + PhantomData, +); + +impl + 'static> WasmRepositoryChannel { + pub(crate) fn new(repository: R) -> Self { + Self(ThreadBoundRunner::new(repository), PhantomData) + } +} + +#[async_trait::async_trait] +impl + 'static> Repository + for WasmRepositoryChannel +{ + async fn get(&self, id: String) -> Result, RepositoryError> { + run_convert(&self.0, |s| async move { s.get(id).await }).await + } + async fn list(&self) -> Result, RepositoryError> { + run_convert(&self.0, |s| async move { s.list().await }).await + } + async fn set(&self, id: String, value: T) -> Result<(), RepositoryError> { + run_convert(&self.0, |s| async move { s.set(id, value).await.and(UNIT) }).await + } + async fn remove(&self, id: String) -> Result<(), RepositoryError> { + run_convert(&self.0, |s| async move { s.remove(id).await.and(UNIT) }).await + } +} + +#[wasm_bindgen(typescript_custom_section)] +const REPOSITORY_CUSTOM_TS_TYPE: &'static str = r#" +export interface Repository { + get(id: string): Promise; + list(): Promise; + set(id: string, value: T): Promise; + remove(id: string): Promise; +} +"#; + +/// This macro generates a [::wasm_bindgen] interface for a repository type, and provides the +/// implementation of [WasmRepository] and a way to convert it into something that implements +/// the [Repository] trait. +macro_rules! create_wasm_repository { + ($name:ident, $ty:ty, $typescript_ty:literal) => { + #[wasm_bindgen] + extern "C" { + #[wasm_bindgen(js_name = $name, typescript_type = $typescript_ty)] + pub type $name; + + #[wasm_bindgen(method, catch)] + async fn get( + this: &$name, + id: String, + ) -> Result<::wasm_bindgen::JsValue, ::wasm_bindgen::JsValue>; + #[wasm_bindgen(method, catch)] + async fn list(this: &$name) + -> Result<::wasm_bindgen::JsValue, ::wasm_bindgen::JsValue>; + #[wasm_bindgen(method, catch)] + async fn set( + this: &$name, + id: String, + value: $ty, + ) -> Result<::wasm_bindgen::JsValue, ::wasm_bindgen::JsValue>; + #[wasm_bindgen(method, catch)] + async fn remove( + this: &$name, + id: String, + ) -> Result<::wasm_bindgen::JsValue, ::wasm_bindgen::JsValue>; + } + + impl $crate::platform::repository::WasmRepository<$ty> for $name { + async fn get( + &self, + id: String, + ) -> Result<::wasm_bindgen::JsValue, ::wasm_bindgen::JsValue> { + self.get(id).await + } + async fn list(&self) -> Result<::wasm_bindgen::JsValue, ::wasm_bindgen::JsValue> { + self.list().await + } + async fn set( + &self, + id: String, + value: $ty, + ) -> Result<::wasm_bindgen::JsValue, ::wasm_bindgen::JsValue> { + self.set(id, value).await + } + async fn remove( + &self, + id: String, + ) -> Result<::wasm_bindgen::JsValue, ::wasm_bindgen::JsValue> { + self.remove(id).await + } + } + + impl $name { + pub fn into_channel_impl( + self, + ) -> ::std::sync::Arc> { + use $crate::platform::repository::WasmRepositoryChannel; + ::std::sync::Arc::new(WasmRepositoryChannel::new(self)) + } + } + }; +} +pub(crate) use create_wasm_repository; + +const UNIT: Result = Ok(JsValue::UNDEFINED); + +/// Utility function that runs a closure in a thread-bound manner, and converts the Result from +/// [Result] to a typed [Result]. +async fn run_convert( + runner: &::bitwarden_threading::ThreadBoundRunner, + f: Func, +) -> Result +where + Func: FnOnce(Rc) -> Fut + Send + 'static, + Fut: Future>, + Ret: serde::de::DeserializeOwned + Send + Sync + 'static, +{ + runner + .run_in_thread(|state| async move { convert_result(f(state).await) }) + .await + .expect("Task should not panic") +} + +/// Converts a [Result] to a typed [Result] using +/// [tsify_next::serde_wasm_bindgen] +fn convert_result( + result: Result, +) -> Result { + result + .map_err(|e| RepositoryError::Internal(format!("{e:?}"))) + .and_then(|value| { + ::tsify_next::serde_wasm_bindgen::from_value(value) + .map_err(|e| RepositoryError::Internal(e.to_string())) + }) +}