diff --git a/Cargo.lock b/Cargo.lock index b15b5b1e4..5e72d65ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2617,7 +2617,7 @@ dependencies = [ [[package]] name = "sargon" -version = "1.1.23" +version = "1.1.24" dependencies = [ "actix-rt", "aes-gcm", diff --git a/apple/Sources/Sargon/SargonOS/SargonOS+Static+Shared.swift b/apple/Sources/Sargon/SargonOS/SargonOS+Static+Shared.swift index 697dc0184..6ecfd9edf 100644 --- a/apple/Sources/Sargon/SargonOS/SargonOS+Static+Shared.swift +++ b/apple/Sources/Sargon/SargonOS/SargonOS+Static+Shared.swift @@ -47,7 +47,7 @@ extension SargonOS { if !isEmulatingFreshInstall, _shared != nil { throw SargonOSAlreadyBooted() } - let shared = try await SargonOS.boot(bios: bios) + let shared = await SargonOS.boot(bios: bios) Self._shared = shared return shared } diff --git a/apple/Sources/Sargon/SargonOS/TestOS.swift b/apple/Sources/Sargon/SargonOS/TestOS.swift index 6dde616e5..a1581d27e 100644 --- a/apple/Sources/Sargon/SargonOS/TestOS.swift +++ b/apple/Sources/Sargon/SargonOS/TestOS.swift @@ -42,9 +42,9 @@ extension TestOS: SargonOSProtocol {} // MARK: Private extension TestOS { - private func nextAccountName() throws -> DisplayName { - let index = try accountsForDisplayOnCurrentNetwork.count - return DisplayName(value: "Unnamed \(index)") + private func nextAccountName() -> DisplayName { + let index = (try? accountsForDisplayOnCurrentNetwork.count) ?? 0 + return DisplayName(value: "Unnamed \(index)") } } diff --git a/apple/Sources/Sargon/Util/EventPublisher.swift b/apple/Sources/Sargon/Util/EventPublisher.swift index 25d78832a..098832d06 100644 --- a/apple/Sources/Sargon/Util/EventPublisher.swift +++ b/apple/Sources/Sargon/Util/EventPublisher.swift @@ -1,11 +1,11 @@ import AsyncExtensions public final actor EventPublisher { - public typealias Subject = AsyncPassthroughSubject - public typealias Stream = AsyncThrowingPassthroughSubject + public typealias Subject = AsyncReplaySubject + public typealias Stream = AsyncThrowingReplaySubject - let stream = Stream() - let subject = Subject() + let stream = Stream(bufferSize: 1) + let subject = Subject(bufferSize: 1) public func eventStream() -> AsyncMulticastSequence { subject diff --git a/apple/Tests/IntegrationTests/DriversTests/DriversTests.swift b/apple/Tests/IntegrationTests/DriversTests/DriversTests.swift index 22da9576a..7de9038b1 100644 --- a/apple/Tests/IntegrationTests/DriversTests/DriversTests.swift +++ b/apple/Tests/IntegrationTests/DriversTests/DriversTests.swift @@ -29,6 +29,6 @@ final class DriversTests: TestCase { } func test_bios_insecure() async throws { - let _ = try await SargonOS.boot(bios: BIOS.insecure()) + let _ = await SargonOS.boot(bios: BIOS.insecure()) } } diff --git a/apple/Tests/IntegrationTests/DriversTests/EventBusDriverTests.swift b/apple/Tests/IntegrationTests/DriversTests/EventBusDriverTests.swift index 47254a5cb..6806e8200 100644 --- a/apple/Tests/IntegrationTests/DriversTests/EventBusDriverTests.swift +++ b/apple/Tests/IntegrationTests/DriversTests/EventBusDriverTests.swift @@ -9,7 +9,7 @@ class EventBusDriverTests: DriverTest { func test() async throws { let sut = SUT() - let expectedEvents = Array([.booted, .profileSaved, .factorSourceUpdated, .accountAdded, .profileSaved]) + let expectedEvents = Array([.booted, .profileSaved, .profileSaved, .factorSourceUpdated, .accountAdded, .profileSaved]) let task = Task { var notifications = Set() for await notification in await sut.notifications().prefix(expectedEvents.count) { diff --git a/apple/Tests/IntegrationTests/DriversTests/InsecureStorageTests.swift b/apple/Tests/IntegrationTests/DriversTests/InsecureStorageTests.swift index 0df0b7101..bbe604124 100644 --- a/apple/Tests/IntegrationTests/DriversTests/InsecureStorageTests.swift +++ b/apple/Tests/IntegrationTests/DriversTests/InsecureStorageTests.swift @@ -9,7 +9,7 @@ class InsecureStorageDriverTests: DriverTest, ); let (_, accounts) = sut diff --git a/crates/sargon/src/profile/logic/account/query_accounts.rs b/crates/sargon/src/profile/logic/account/query_accounts.rs index 56640e284..9d210107e 100644 --- a/crates/sargon/src/profile/logic/account/query_accounts.rs +++ b/crates/sargon/src/profile/logic/account/query_accounts.rs @@ -3,18 +3,20 @@ use crate::prelude::*; impl Profile { /// Returns the non-hidden accounts on the current network, empty if no accounts /// on the network - pub fn accounts_on_current_network(&self) -> Accounts { - self.current_network().accounts.non_hidden() + pub fn accounts_on_current_network(&self) -> Result { + self.current_network().map(|n| n.accounts.non_hidden()) } /// Returns the non-hidden accounts on the current network as `AccountForDisplay` pub fn accounts_for_display_on_current_network( &self, - ) -> AccountsForDisplay { - self.accounts_on_current_network() - .iter() - .map(AccountForDisplay::from) - .collect::() + ) -> Result { + self.accounts_on_current_network().map(|accounts| { + accounts + .iter() + .map(AccountForDisplay::from) + .collect::() + }) } /// Looks up the account by account address, returns Err if the account is @@ -44,7 +46,7 @@ mod tests { fn test_accounts_on_current_network() { let sut = SUT::sample(); assert_eq!( - sut.accounts_on_current_network(), + sut.accounts_on_current_network().unwrap(), Accounts::sample_mainnet() ); } @@ -53,7 +55,7 @@ mod tests { fn test_accounts_on_current_network_stokenet() { let sut = SUT::sample_other(); assert_eq!( - sut.accounts_on_current_network(), + sut.accounts_on_current_network().unwrap(), Accounts::just(Account::sample_stokenet_nadia()) // olivia is hidden ); } @@ -62,7 +64,7 @@ mod tests { fn test_accounts_for_display_on_current_network() { let sut = SUT::sample(); assert_eq!( - sut.accounts_for_display_on_current_network(), + sut.accounts_for_display_on_current_network().unwrap(), Accounts::sample_mainnet() .iter() .map(AccountForDisplay::from) diff --git a/crates/sargon/src/profile/logic/gateway/current_gateway.rs b/crates/sargon/src/profile/logic/gateway/current_gateway.rs index c8aa36272..a08189df3 100644 --- a/crates/sargon/src/profile/logic/gateway/current_gateway.rs +++ b/crates/sargon/src/profile/logic/gateway/current_gateway.rs @@ -16,10 +16,13 @@ impl Profile { /// The ProfileNetwork of the currently used Network dependent on the `current` /// Gateway set in AppPreferences. This affects which Accounts users see in /// "Home screen" in wallet apps. - pub fn current_network(&self) -> &ProfileNetwork { - self.networks - .get_id(self.current_network_id()) - .expect("Should have current network") + pub fn current_network(&self) -> Result<&ProfileNetwork> { + let current_network_id = self.current_network_id(); + self.networks.get_id(current_network_id).ok_or( + CommonError::NoNetworkInProfile { + network_id: current_network_id, + }, + ) } } diff --git a/crates/sargon/src/profile/v100/header/mod.rs b/crates/sargon/src/profile/v100/header/mod.rs index 04a620759..3629d6776 100644 --- a/crates/sargon/src/profile/v100/header/mod.rs +++ b/crates/sargon/src/profile/v100/header/mod.rs @@ -7,6 +7,7 @@ mod device_info_uniffi_fn; mod header; mod header_uniffi_fn; mod profile_id; +mod profile_id_uniffi_fn; pub use content_hint::*; pub use device_id::*; @@ -17,3 +18,4 @@ pub use device_info_uniffi_fn::*; pub use header::*; pub use header_uniffi_fn::*; pub use profile_id::*; +pub use profile_id_uniffi_fn::*; diff --git a/crates/sargon/src/profile/v100/header/profile_id.rs b/crates/sargon/src/profile/v100/header/profile_id.rs index da6d81a9a..d1e61395c 100644 --- a/crates/sargon/src/profile/v100/header/profile_id.rs +++ b/crates/sargon/src/profile/v100/header/profile_id.rs @@ -14,7 +14,6 @@ use crate::prelude::*; )] #[serde(transparent)] pub struct ProfileID(pub(crate) Uuid); -uniffi::custom_newtype!(ProfileID, Uuid); impl FromStr for ProfileID { type Err = CommonError; diff --git a/crates/sargon/src/profile/v100/header/profile_id_uniffi_fn.rs b/crates/sargon/src/profile/v100/header/profile_id_uniffi_fn.rs new file mode 100644 index 000000000..11fac71c2 --- /dev/null +++ b/crates/sargon/src/profile/v100/header/profile_id_uniffi_fn.rs @@ -0,0 +1,37 @@ +use crate::prelude::*; + +uniffi::custom_newtype!(ProfileID, Uuid); + +#[uniffi::export] +pub fn new_profile_id_sample() -> ProfileID { + ProfileID::sample() +} + +#[uniffi::export] +pub fn new_profile_id_sample_other() -> ProfileID { + ProfileID::sample_other() +} + +#[cfg(test)] +mod uniffi_test { + + use super::*; + + #[allow(clippy::upper_case_acronyms)] + type SUT = ProfileID; + + #[test] + fn hash_of_samples() { + assert_eq!( + HashSet::::from_iter([ + new_profile_id_sample(), + new_profile_id_sample_other(), + // duplicates should get removed + new_profile_id_sample(), + new_profile_id_sample_other(), + ]) + .len(), + 2 + ); + } +} diff --git a/crates/sargon/src/profile/v100/networks/network/profile_network.rs b/crates/sargon/src/profile/v100/networks/network/profile_network.rs index 745bbea87..671fc17bd 100644 --- a/crates/sargon/src/profile/v100/networks/network/profile_network.rs +++ b/crates/sargon/src/profile/v100/networks/network/profile_network.rs @@ -142,6 +142,21 @@ impl ProfileNetwork { ResourcePreferences::new(), ) } + + /// Instantiates a new `ProfileNetwork` from `network_id` and `accounts`, with all + /// the rest i.e. Personas, AuthorizedDapps all being empty. + pub fn new_with_accounts( + network_id: impl Into, + accounts: impl Into, + ) -> Self { + Self::new( + network_id, + accounts, + Personas::new(), + AuthorizedDapps::new(), + ResourcePreferences::new(), + ) + } } impl ProfileNetwork { @@ -310,6 +325,20 @@ mod tests { ); } + #[test] + #[should_panic( + expected = "Discrepancy, found an AuthorizedDapp on other network than mainnet" + )] + fn panic_when_network_id_mismatch_between_accounts_when_new_() { + SUT::new( + NetworkID::Mainnet, + Accounts::sample_mainnet(), + Personas::sample_mainnet(), + AuthorizedDapps::just(AuthorizedDapp::sample_stokenet()), + ResourcePreferences::default(), + ); + } + #[test] fn json_roundtrip_sample_mainnet() { let sut = SUT::sample_mainnet(); diff --git a/crates/sargon/src/profile/v100/networks/profile_networks.rs b/crates/sargon/src/profile/v100/networks/profile_networks.rs index 0de952931..51e6a9ec8 100644 --- a/crates/sargon/src/profile/v100/networks/profile_networks.rs +++ b/crates/sargon/src/profile/v100/networks/profile_networks.rs @@ -34,7 +34,13 @@ impl ProfileNetworks { pub fn content_hint(&self) -> ContentHint { let number_of_accounts = self.iter().fold(0, |acc, x| acc + x.accounts.len()); - ContentHint::with_counters(number_of_accounts, 0, self.len()) + let number_of_personas = + self.iter().fold(0, |per, x| per + x.personas.len()); + ContentHint::with_counters( + number_of_accounts, + number_of_personas, + self.len(), + ) } } @@ -218,7 +224,7 @@ mod tests { fn content_hint() { assert_eq!( SUT::sample().content_hint(), - ContentHint::with_counters(4, 0, 2) + ContentHint::with_counters(4, 4, 2) ); } diff --git a/crates/sargon/src/profile/v100/profile.rs b/crates/sargon/src/profile/v100/profile.rs index 1d37537b2..314a09e92 100644 --- a/crates/sargon/src/profile/v100/profile.rs +++ b/crates/sargon/src/profile/v100/profile.rs @@ -88,10 +88,9 @@ impl Profile { } impl Profile { - /// Creates a new Profile from the `DeviceFactorSource` and `DeviceInfo`. + /// Creates a new Profile from the `DeviceFactorSource` and `DeviceInfo` and some [Accounts] /// - /// The Profile is initialized with a Mainnet ProfileNetwork, which is - /// "empty" (no Accounts, Personas etc). + /// The Profile is initialized with a Mainnet ProfileNetwork, and some [Accounts] in it. /// /// # Panics /// Panics if the `device_factor_source` is not a BDFS and not marked "main". @@ -99,6 +98,7 @@ impl Profile { device_factor_source: DeviceFactorSource, host_id: HostId, host_info: HostInfo, + maybe_accounts: Option>, ) -> Self { if !device_factor_source.is_main_bdfs() { panic!("DeviceFactorSource is not main BDFS"); @@ -106,13 +106,20 @@ impl Profile { let bdfs = device_factor_source; let header = Header::new(DeviceInfo::new_from_info(&host_id, &host_info)); + + let mainnet_network = match maybe_accounts { + None => ProfileNetwork::new_empty_on(NetworkID::Mainnet), + Some(accounts) => ProfileNetwork::new_with_accounts( + NetworkID::Mainnet, + accounts.into(), + ), + }; + Self::with( header, FactorSources::with_bdfs(bdfs), AppPreferences::default(), - ProfileNetworks::just(ProfileNetwork::new_empty_on( - NetworkID::Mainnet, - )), + ProfileNetworks::just(mainnet_network), ) } @@ -132,7 +139,12 @@ impl Profile { &mnemonic_with_passphrase, &host_info, ); - Self::from_device_factor_source(bdfs, host_id, host_info) + Self::from_device_factor_source( + bdfs, + host_id, + host_info, + None::, + ) } /// Creates a new Profile from the `Mnemonic` (no passphrase) and `DeviceInfo`, @@ -429,6 +441,34 @@ mod tests { DeviceFactorSource::sample_other(), HostId::sample(), HostInfo::sample(), + None::, + ); + } + + #[test] + fn new_from_main_bdfs_with_accounts() { + let accounts = Accounts::sample_mainnet(); + let profile = SUT::from_device_factor_source( + DeviceFactorSource::sample(), + HostId::sample(), + HostInfo::sample(), + Some(accounts), + ); + + assert!(profile.has_any_account_on_any_network()) + } + + #[test] + #[should_panic( + expected = "Discrepancy, found an Account on other network than mainnet" + )] + fn new_from_main_bdfs_with_stokenet_accounts_panics() { + let accounts = Accounts::sample_stokenet(); + SUT::from_device_factor_source( + DeviceFactorSource::sample(), + HostId::sample(), + HostInfo::sample(), + Some(accounts), ); } @@ -778,7 +818,7 @@ mod tests { "lastModified": "2023-09-11T16:05:56.000Z", "contentHint": { "numberOfAccountsOnAllNetworksInTotal": 4, - "numberOfPersonasOnAllNetworksInTotal": 0, + "numberOfPersonasOnAllNetworksInTotal": 4, "numberOfNetworks": 2 } }, diff --git a/crates/sargon/src/profile/v100/profile_uniffi_fn.rs b/crates/sargon/src/profile/v100/profile_uniffi_fn.rs index 39da15a5a..41342f83c 100644 --- a/crates/sargon/src/profile/v100/profile_uniffi_fn.rs +++ b/crates/sargon/src/profile/v100/profile_uniffi_fn.rs @@ -49,7 +49,12 @@ pub fn new_profile( host_id: HostId, host_info: HostInfo, ) -> Profile { - Profile::from_device_factor_source(device_factor_source, host_id, host_info) + Profile::from_device_factor_source( + device_factor_source, + host_id, + host_info, + None::, + ) } #[uniffi::export] diff --git a/crates/sargon/src/system/clients/client/secure_storage_client/secure_storage_client.rs b/crates/sargon/src/system/clients/client/secure_storage_client/secure_storage_client.rs index fb192da52..38c8f818f 100644 --- a/crates/sargon/src/system/clients/client/secure_storage_client/secure_storage_client.rs +++ b/crates/sargon/src/system/clients/client/secure_storage_client/secure_storage_client.rs @@ -94,7 +94,7 @@ impl SecureStorageClient { /// Loads the Profile. pub async fn load_profile(&self) -> Result> { debug!("Loading profile"); - self.load(SecureStorageKey::ProfileSnapshot) + self.load(SecureStorageKey::load_profile_snapshot()) .await .inspect(|some_profile| { if some_profile.is_some() { @@ -110,10 +110,15 @@ impl SecureStorageClient { pub async fn save_profile(&self, profile: &Profile) -> Result<()> { let profile_id = profile.id(); debug!("Saving profile with id: {}", profile_id); - self.save(SecureStorageKey::ProfileSnapshot, profile) - .await - .inspect(|_| debug!("Saved profile with id {}", profile_id)) - .inspect_err(|e| error!("Failed to save profile, error {e}")) + self.save( + SecureStorageKey::ProfileSnapshot { + profile_id: profile.id(), + }, + profile, + ) + .await + .inspect(|_| debug!("Saved profile with id {}", profile_id)) + .inspect_err(|e| error!("Failed to save profile, error {e}")) } //====== @@ -167,9 +172,6 @@ impl SecureStorageClient { mnemonic_with_passphrase, ) .await - .map_err(|_| { - CommonError::UnableToSaveMnemonicToSecureStorage { bad_value: *id } - }) } /// Loads a MnemonicWithPassphrase with a `FactorSourceIDFromHash` @@ -203,7 +205,9 @@ impl SecureStorageClient { pub async fn delete_profile(&self, id: ProfileID) -> Result<()> { warn!("Deleting profile with id: {}", id); self.driver - .delete_data_for_key(SecureStorageKey::ProfileSnapshot) + .delete_data_for_key(SecureStorageKey::ProfileSnapshot { + profile_id: id, + }) .await } } @@ -233,7 +237,8 @@ mod tests { async fn load_ok_when_none() { let sut = make_sut(); assert_eq!( - sut.load::(SecureStorageKey::ProfileSnapshot).await, + sut.load::(SecureStorageKey::load_profile_snapshot()) + .await, Ok(None) ); } @@ -242,13 +247,22 @@ mod tests { async fn load_successful() { let sut = make_sut(); + let profile = Profile::sample(); assert!(sut - .save(SecureStorageKey::ProfileSnapshot, &Profile::sample()) + .save( + SecureStorageKey::ProfileSnapshot { + profile_id: profile.id() + }, + &profile + ) .await .is_ok()); assert_eq!( - sut.load::(SecureStorageKey::ProfileSnapshot).await, - Ok(Some(Profile::sample())) + sut.load::(SecureStorageKey::ProfileSnapshot { + profile_id: profile.id() + }) + .await, + Ok(Some(profile)) ); } @@ -256,17 +270,25 @@ mod tests { async fn load_unwrap_or_some_default_not_used() { let sut = make_sut(); + let profile = Profile::sample(); assert!(sut - .save(SecureStorageKey::ProfileSnapshot, &Profile::sample()) + .save( + SecureStorageKey::ProfileSnapshot { + profile_id: profile.id() + }, + &profile + ) .await .is_ok()); assert_eq!( sut.load_unwrap_or::( - SecureStorageKey::ProfileSnapshot, - Profile::sample_other() + SecureStorageKey::ProfileSnapshot { + profile_id: profile.id() + }, + profile.clone() ) .await, - Profile::sample() + profile ); } @@ -276,7 +298,7 @@ mod tests { assert_eq!( sut.load_unwrap_or::( - SecureStorageKey::ProfileSnapshot, + SecureStorageKey::load_profile_snapshot(), Profile::sample_other() ) .await, @@ -309,22 +331,6 @@ mod tests { .contains("device")); } - #[actix_rt::test] - async fn save_mnemonic_with_passphrase_failure() { - let sut = SecureStorageClient::always_fail(); - let id = FactorSourceIDFromHash::sample(); - assert_eq!( - sut.save_mnemonic_with_passphrase( - &MnemonicWithPassphrase::sample(), - &id - ) - .await, - Err(CommonError::UnableToSaveMnemonicToSecureStorage { - bad_value: id - }) - ); - } - #[actix_rt::test] async fn delete_mnemonic() { // ARRANGE @@ -369,7 +375,7 @@ mod tests { let (sut, _) = SecureStorageClient::ephemeral(); assert_eq!( sut.save( - SecureStorageKey::ProfileSnapshot, + SecureStorageKey::load_profile_snapshot(), &AlwaysFailSerialize {} ) .await, diff --git a/crates/sargon/src/system/drivers/drivers.rs b/crates/sargon/src/system/drivers/drivers.rs index 791ca3c41..ef65309fe 100644 --- a/crates/sargon/src/system/drivers/drivers.rs +++ b/crates/sargon/src/system/drivers/drivers.rs @@ -177,6 +177,22 @@ impl Drivers { RustProfileStateChangeDriver::new(), ) } + + pub fn with_profile_state_change( + profile_state_change: Arc, + ) -> Arc { + Drivers::new( + RustNetworkingDriver::new(), + EphemeralSecureStorage::new(), + RustEntropyDriver::new(), + RustHostInfoDriver::new(), + RustLoggingDriver::new(), + RustEventBusDriver::new(), + RustFileSystemDriver::new(), + EphemeralUnsafeStorage::new(), + profile_state_change, + ) + } } #[cfg(test)] diff --git a/crates/sargon/src/system/drivers/secure_storage_driver/support/mod.rs b/crates/sargon/src/system/drivers/secure_storage_driver/support/mod.rs index 42cb13564..0247ff8d2 100644 --- a/crates/sargon/src/system/drivers/secure_storage_driver/support/mod.rs +++ b/crates/sargon/src/system/drivers/secure_storage_driver/support/mod.rs @@ -1,5 +1,7 @@ +mod secure_storage_access_error_kind; mod secure_storage_key; mod test; +pub use secure_storage_access_error_kind::*; pub use secure_storage_key::*; pub use test::*; diff --git a/crates/sargon/src/system/drivers/secure_storage_driver/support/secure_storage_access_error_kind.rs b/crates/sargon/src/system/drivers/secure_storage_driver/support/secure_storage_access_error_kind.rs new file mode 100644 index 000000000..5cb4947e0 --- /dev/null +++ b/crates/sargon/src/system/drivers/secure_storage_driver/support/secure_storage_access_error_kind.rs @@ -0,0 +1,134 @@ +use crate::prelude::*; + +/// An error kind that might be returned during access to secure storage driver. These errors are +/// android specific and are defined [here](https://developer.android.com/reference/android/hardware/biometrics/BiometricPrompt#constants_1) +/// Hosts, can print the error message provided by the system, and can ignore the error if +/// it `is_manual_cancellation`. +#[derive(Clone, Debug, PartialEq, strum::EnumIter, uniffi::Enum)] +pub enum SecureStorageAccessErrorKind { + /// The hardware is unavailable. Try again later. + HardwareUnavailable, + + /// The sensor was unable to process the current image. + UnableToProcess, + + /// The current operation has been running too long and has timed out. + // This is intended to prevent programs from waiting for the biometric sensor indefinitely. + // The timeout is platform and sensor-specific, but is generally on the order of ~30 seconds. + Timeout, + + /// The operation can't be completed because there is not enough device storage remaining. + NoSpace, + + /// The operation was canceled because the biometric sensor is unavailable. + /// This may happen when the user is switched, the device is locked, or another + /// pending operation prevents it. + Cancelled, + + /// The operation was canceled because the API is locked out due to too many attempts. + /// This occurs after 5 failed attempts, and lasts for 30 seconds. + Lockout, + + /// The operation failed due to a vendor-specific error. + /// This error kind may be used by hardware vendors to extend this list to cover + /// errors that don't fall under one of the other predefined categories. Vendors are + /// responsible for providing the strings for these errors. + /// + /// These messages are typically reserved for internal operations such as enrollment + /// but may be used to express any error that is not otherwise covered. + /// In this case, applications are expected to show the error message, but they are advised + /// not to rely on the message ID, since this may vary by vendor and device. + Vendor, + + /// The operation was canceled because `Lockout` occurred too many times. Biometric + /// authentication is disabled until the user unlocks with their device credential + /// (i.e. PIN, pattern, or password). + LockoutPermanent, + + /// The user canceled the operation. + /// Upon receiving this, applications should use alternate authentication, such as a password. + /// The application should also provide the user a way of returning to biometric authentication, + /// such as a button. + UserCancelled, + + /// The user does not have any biometrics enrolled. + NoBiometrics, + + /// The device does not have the required authentication hardware. + HardwareNotPresent, + + /// The user pressed the negative button. + NegativeButton, + + /// The device does not have pin, pattern, or password set up. + NoDeviceCredential, +} + +impl SecureStorageAccessErrorKind { + pub fn is_manual_cancellation(&self) -> bool { + self == &SecureStorageAccessErrorKind::UserCancelled + || self == &SecureStorageAccessErrorKind::NegativeButton + } +} + +#[uniffi::export] +pub fn secure_storage_access_error_kind_is_manual_cancellation( + kind: SecureStorageAccessErrorKind, +) -> bool { + kind.is_manual_cancellation() +} + +#[cfg(test)] +mod tests { + use crate::prelude::*; + + #[test] + fn user_cancelled_is_manual() { + let sut = SecureStorageAccessErrorKind::UserCancelled; + assert!(sut.is_manual_cancellation()) + } + + #[test] + fn negative_button_is_manual() { + let sut = SecureStorageAccessErrorKind::NegativeButton; + assert!(sut.is_manual_cancellation()) + } + + #[test] + fn rest_are_system_errors() { + assert!(!SecureStorageAccessErrorKind::HardwareUnavailable + .is_manual_cancellation()); + assert!(!SecureStorageAccessErrorKind::UnableToProcess + .is_manual_cancellation()); + assert!(!SecureStorageAccessErrorKind::Timeout.is_manual_cancellation()); + assert!(!SecureStorageAccessErrorKind::NoSpace.is_manual_cancellation()); + assert!( + !SecureStorageAccessErrorKind::Cancelled.is_manual_cancellation() + ); + assert!(!SecureStorageAccessErrorKind::Lockout.is_manual_cancellation()); + assert!(!SecureStorageAccessErrorKind::Vendor.is_manual_cancellation()); + assert!(!SecureStorageAccessErrorKind::LockoutPermanent + .is_manual_cancellation()); + assert!(!SecureStorageAccessErrorKind::NoBiometrics + .is_manual_cancellation()); + assert!(!SecureStorageAccessErrorKind::HardwareNotPresent + .is_manual_cancellation()); + assert!(!SecureStorageAccessErrorKind::NoDeviceCredential + .is_manual_cancellation()); + } +} + +#[cfg(test)] +mod uniffi_tests { + use crate::prelude::*; + + #[test] + fn test() { + for kind in SecureStorageAccessErrorKind::iter() { + assert_eq!( + kind.is_manual_cancellation(), + secure_storage_access_error_kind_is_manual_cancellation(kind) + ); + } + } +} diff --git a/crates/sargon/src/system/drivers/secure_storage_driver/support/secure_storage_key.rs b/crates/sargon/src/system/drivers/secure_storage_driver/support/secure_storage_key.rs index b47c7616f..e9503d048 100644 --- a/crates/sargon/src/system/drivers/secure_storage_driver/support/secure_storage_key.rs +++ b/crates/sargon/src/system/drivers/secure_storage_driver/support/secure_storage_key.rs @@ -1,12 +1,60 @@ use crate::prelude::*; +use std::hash::{Hash, Hasher}; -#[derive(Debug, Clone, PartialEq, Eq, Hash, uniffi::Enum)] +#[derive(Debug, Clone, Eq, uniffi::Enum)] pub enum SecureStorageKey { HostID, DeviceFactorSourceMnemonic { factor_source_id: FactorSourceIDFromHash, }, - ProfileSnapshot, + ProfileSnapshot { + // Note: + // `profile_id` is only meant to be used by the iOS Host for backward compatibility. + // iOS Host stores multiple profiles in the secure storage uniquely identified by `profile_id`, + // while Android Host stores only one profile in the secure storage. + profile_id: ProfileID, + }, +} + +impl PartialEq for SecureStorageKey { + fn eq(&self, other: &SecureStorageKey) -> bool { + match (self, other) { + (SecureStorageKey::HostID, SecureStorageKey::HostID) => true, + ( + SecureStorageKey::DeviceFactorSourceMnemonic { + factor_source_id: a, + }, + SecureStorageKey::DeviceFactorSourceMnemonic { + factor_source_id: b, + }, + ) => a == b, + ( + SecureStorageKey::ProfileSnapshot { .. }, + SecureStorageKey::ProfileSnapshot { .. }, + ) => true, // Note: `profile_id` is not used for comparison, as it is only forwarded as additional payload to the iOS Host. + _ => false, + } + } +} + +impl Hash for SecureStorageKey { + fn hash(&self, state: &mut H) { + match self { + SecureStorageKey::HostID => { + "host_id".hash(state); + } + SecureStorageKey::DeviceFactorSourceMnemonic { + factor_source_id, + } => { + "device_factor_source".hash(state); + factor_source_id.hash(state); + } + // Note: `profile_id` is not used for computing the hash, as it is only forwarded as additional payload to the iOS Host. + SecureStorageKey::ProfileSnapshot { .. } => { + "profile_snapshot".hash(state); + } + } + } } impl SecureStorageKey { @@ -19,13 +67,24 @@ impl SecureStorageKey { SecureStorageKey::DeviceFactorSourceMnemonic { factor_source_id, } => format!("device_factor_source_{}", factor_source_id), - SecureStorageKey::ProfileSnapshot => + SecureStorageKey::ProfileSnapshot { .. } => "profile_snapshot".to_owned(), } ) } } +impl SecureStorageKey { + pub fn load_profile_snapshot() -> Self { + // This id will not be used to load the profile snapshot. + // It is only a stub to conform to the SecureStorageKey definition. + let dummy_id = ProfileID(Uuid::from_bytes([0x00; 16])); + SecureStorageKey::ProfileSnapshot { + profile_id: dummy_id, + } + } +} + #[uniffi::export] pub fn secure_storage_key_identifier(key: &SecureStorageKey) -> String { key.identifier() @@ -45,7 +104,7 @@ mod tests { "secure_storage_key_device_factor_source_device:f1a93d324dd0f2bff89963ab81ed6e0c2ee7e18c0827dc1d3576b2d9f26bbd0a" ); assert_eq!( - SecureStorageKey::ProfileSnapshot.identifier(), + SecureStorageKey::load_profile_snapshot().identifier(), "secure_storage_key_profile_snapshot" ); } @@ -57,7 +116,7 @@ mod uniffi_tests { #[test] fn identifier() { - let key = SecureStorageKey::ProfileSnapshot; + let key = SecureStorageKey::load_profile_snapshot(); assert_eq!( key.clone().identifier(), secure_storage_key_identifier(&key) diff --git a/crates/sargon/src/system/sargon_os/profile_state_holder.rs b/crates/sargon/src/system/sargon_os/profile_state_holder.rs index 36a0bef78..1787a74cc 100644 --- a/crates/sargon/src/system/sargon_os/profile_state_holder.rs +++ b/crates/sargon/src/system/sargon_os/profile_state_holder.rs @@ -1,7 +1,9 @@ use crate::prelude::*; use std::{borrow::Borrow, sync::RwLock}; -#[derive(Debug, Clone, PartialEq, derive_more::Display, uniffi::Enum)] +#[derive( + Debug, Clone, PartialEq, EnumAsInner, derive_more::Display, uniffi::Enum, +)] #[allow(clippy::large_enum_variant)] pub enum ProfileState { /// When no profile exists in secure storage when OS is booted. @@ -51,13 +53,13 @@ impl ProfileStateHolder { } pub fn current_network(&self) -> Result { - self.access_profile_with(|p| p.current_network().clone()) + self.try_access_profile_with(|p| p.current_network().map(|n| n.clone())) } /// Returns the non-hidden accounts on the current network, empty if no accounts /// on the network pub fn accounts_on_current_network(&self) -> Result { - self.access_profile_with(|p| p.accounts_on_current_network()) + self.try_access_profile_with(|p| p.accounts_on_current_network()) } /// Returns all the SecurityStructuresOfFactorSources, @@ -74,7 +76,7 @@ impl ProfileStateHolder { pub fn accounts_for_display_on_current_network( &self, ) -> Result { - self.access_profile_with(|p| { + self.try_access_profile_with(|p| { p.accounts_for_display_on_current_network() }) } @@ -92,10 +94,9 @@ impl ProfileStateHolder { where F: Fn(&Profile) -> T, { - let guard = self - .profile_state - .try_read() - .expect("Implementing hosts should not read and write Profile from multiple threads."); + let guard = self.profile_state.read().expect( + "Stop execution due to the profile state lock being poisoned", + ); let state = &*guard; match state { @@ -111,10 +112,9 @@ impl ProfileStateHolder { where F: Fn(&Profile) -> Result, { - let guard = self - .profile_state - .try_read() - .expect("Implementing hosts should not read and write Profile from multiple threads."); + let guard = self.profile_state.read().expect( + "Stop execution due to the profile state lock being poisoned", + ); let state = &*guard; match state { @@ -131,10 +131,9 @@ impl ProfileStateHolder { &self, profile_state: ProfileState, ) -> Result<()> { - let mut lock = self - .profile_state - .try_write() - .map_err(|_| CommonError::UnableToAcquireWriteLockForProfile)?; + let mut lock = self.profile_state.write().expect( + "Stop execution due to the profile state lock being poisoned", + ); *lock = profile_state; Ok(()) @@ -147,25 +146,27 @@ impl ProfileStateHolder { where F: Fn(&mut Profile) -> Result, { - self.profile_state - .try_write() - .map_err(|_| CommonError::UnableToAcquireWriteLockForProfile) - .and_then(|mut guard| { - let state = &mut *guard; - - match state { - ProfileState::Loaded(ref mut profile) => mutate(profile), - _ => Err(CommonError::ProfileStateNotLoaded { - current_state: state.to_string(), - }), - } - }) + let mut guard = self.profile_state.write().expect( + "Stop execution due to the profile state lock being poisoned", + ); + + let state = &mut *guard; + + match state { + ProfileState::Loaded(ref mut profile) => mutate(profile), + _ => Err(CommonError::ProfileStateNotLoaded { + current_state: state.to_string(), + }), + } } } #[cfg(test)] mod tests { use crate::prelude::*; + use std::sync::{Arc, RwLock}; + use std::thread; + use std::time::Duration; #[test] fn test_new_none_profile_state_holder() { @@ -208,4 +209,150 @@ mod tests { state, ) } + + #[test] + fn test_concurrent_access_read_after_write() { + let state = ProfileState::Loaded(Profile::sample()); + let sut = ProfileStateHolder::new(state.clone()); + let state_holder = Arc::new(sut); + + let state_holder_clone = Arc::clone(&state_holder); + + // Spawn a thread that acquires a write lock + let handle = thread::spawn(move || { + let _write_lock = + state_holder_clone.update_profile_with(|profile| { + profile.networks.try_update_with( + &NetworkID::Mainnet, + |network| { + let _res = network.accounts.try_insert_unique( + Account::sample_mainnet_carol(), + ); + }, + ) + }); + thread::sleep(Duration::from_millis(200)); + }); + + // Give the other thread time to acquire the write lock + thread::sleep(Duration::from_millis(100)); + + let mainnet_accounts = state_holder.current_network().unwrap().accounts; + + handle.join().unwrap(); // Wait for the thread to finish + + let mut expected_accounts = Accounts::sample_mainnet(); + expected_accounts.insert(Account::sample_mainnet_carol()); + pretty_assertions::assert_eq!(mainnet_accounts, expected_accounts) + } + + #[test] + fn test_concurrent_access_writes_order_is_preserved() { + let profile = Profile::sample(); + let state = ProfileState::Loaded(profile); + let sut = ProfileStateHolder::new(state.clone()); + let state_holder = Arc::new(sut); + + let first_mainnet_account = state_holder + .access_profile_with(|profile| { + profile + .networks + .first() + .unwrap() + .accounts + .first() + .unwrap() + .clone() + }) + .unwrap(); + + let mut handles = vec![]; + + for i in 0..5 { + let state_holder_clone = Arc::clone(&state_holder); + let handle = thread::spawn(move || { + let _write_lock = + state_holder_clone.update_profile_with(|profile| { + profile.networks.try_update_with( + &NetworkID::Mainnet, + |network| { + let _res = network.accounts.try_update_with( + &first_mainnet_account.address, + |account| { + let display_name = + account.display_name.value.clone(); + account.display_name = DisplayName::new( + display_name + + i.to_string().as_str(), + ) + .unwrap() + }, + ); + }, + ) + }); + // Hold the lock for a while to simulate a long-running write operation + thread::sleep(Duration::from_millis(200)); + }); + thread::sleep(Duration::from_millis(100)); + + handles.push(handle); + } + + for handle in handles { + handle.join().unwrap(); + } + + let result_name = state_holder + .access_profile_with(|profile| { + profile + .networks + .first() + .unwrap() + .accounts + .first() + .unwrap() + .display_name + .value + .clone() + }) + .unwrap(); + + let expected_name = first_mainnet_account.display_name.value + "01234"; + + pretty_assertions::assert_eq!(expected_name, result_name) + } + + #[test] + #[should_panic] + fn test_concurrent_access_poisoned_lock_panics() { + let state = ProfileState::Loaded(Profile::sample()); + let sut = ProfileStateHolder::new(state.clone()); + let state_holder = Arc::new(sut); + + let state_holder_clone = Arc::clone(&state_holder); + + // Spawn a thread that acquires a write lock + let handle = thread::spawn(move || { + let _write_lock = + state_holder_clone.update_profile_with(|profile| { + profile.networks.try_update_with( + &NetworkID::Mainnet, + |network| { + let _res = network + .accounts + .try_insert_unique( + Account::sample_mainnet_carol(), + ) + .unwrap(); + panic!("Simulate panic in thread"); + }, + ) + }); + }); + + let _ = handle.join(); // Wait for the thread to finish + + state_holder.current_network().unwrap(); + } } diff --git a/crates/sargon/src/system/sargon_os/sargon_os.rs b/crates/sargon/src/system/sargon_os/sargon_os.rs index 5bd49f59d..d20a6aac0 100644 --- a/crates/sargon/src/system/sargon_os/sargon_os.rs +++ b/crates/sargon/src/system/sargon_os/sargon_os.rs @@ -38,14 +38,29 @@ impl SargonOS { info!("Host: {}", host_info); let secure_storage = &clients.secure_storage; - let profile_state = secure_storage.load_profile().await.map_or_else( - ProfileState::Incompatible, - |some_profile| { + let mut profile_state = secure_storage + .load_profile() + .await + .map_or_else(ProfileState::Incompatible, |some_profile| { some_profile .map(ProfileState::Loaded) .unwrap_or(ProfileState::None) - }, - ); + }); + + // If an ephemeral profile was created (a profile with no networks) then it is not + // considered as a Loaded profile. + if let Some(profile) = profile_state.as_loaded() + && profile.networks.is_empty() + { + // Delete profile and its associated mnemonics + let device_factor_sources = profile.device_factor_sources(); + for dfs in device_factor_sources.iter() { + let _ = secure_storage.delete_mnemonic(&dfs.id).await; + } + let _ = secure_storage.delete_profile(profile.id()).await; + + profile_state = ProfileState::None; + } let os = Arc::new(Self { clients, @@ -72,22 +87,15 @@ impl SargonOS { self.secure_storage .save_private_hd_factor_source(&bdfs) .await?; - let save_profile_result = - self.secure_storage.save_profile(&profile).await; - if let Some(error) = save_profile_result.err() { + + let set_profile_result = self.set_profile(profile).await; + if let Some(error) = set_profile_result.err() { self.secure_storage .delete_mnemonic(&bdfs.factor_source.id) .await?; return Err(error); } - self.profile_state_holder.replace_profile_state_with( - ProfileState::Loaded(profile.clone()), - )?; - self.clients - .profile_state_change - .emit(ProfileState::Loaded(profile)) - .await; info!("Saved new Profile and BDFS, finish creating wallet"); Ok(()) @@ -151,6 +159,51 @@ impl SargonOS { Ok(()) } + pub async fn new_wallet_with_derived_bdfs( + &self, + hd_factor_source: PrivateHierarchicalDeterministicFactorSource, + accounts: Accounts, + ) -> Result<()> { + debug!("Deriving Profile from BDFS"); + + let hd_keys: Vec = accounts + .iter() + .map(|account| { + account + .security_state + .into_unsecured() + .map(|c| c.transaction_signing.public_key) + .map_err(|_| CommonError::EntitiesNotDerivedByFactorSource) + }) + .try_collect()?; + + if !hd_factor_source + .mnemonic_with_passphrase + .validate_public_keys(hd_keys) + { + return Err(CommonError::EntitiesNotDerivedByFactorSource); + } + + self.secure_storage + .save_private_hd_factor_source(&hd_factor_source) + .await?; + + let host_id = self.host_id().await?; + let host_info = self.host_info().await; + + let profile = Profile::from_device_factor_source( + hd_factor_source.factor_source, + host_id, + host_info, + Some(accounts), + ); + + self.set_profile(profile).await?; + + info!("Successfully derived Profile"); + Ok(()) + } + pub async fn delete_wallet(&self) -> Result<()> { self.delete_profile_and_mnemonics_replace_in_memory_with_none() .await?; @@ -204,10 +257,11 @@ impl SargonOS { debug!("Created BDFS (unsaved)"); debug!("Creating new Profile..."); - let profile = Profile::from_device_factor_source( - private_bdfs.factor_source.clone(), - host_id, - host_info, + let profile = Profile::with( + Header::new(DeviceInfo::new_from_info(&host_id, &host_info)), + FactorSources::with_bdfs(private_bdfs.factor_source.clone()), + AppPreferences::default(), + ProfileNetworks::default(), ); info!("Created new (unsaved) Profile with ID {}", profile.id()); Ok((profile, private_bdfs)) @@ -274,10 +328,15 @@ impl SargonOS { let test_drivers = Drivers::test(); let bios = Bios::new(test_drivers); let os = Self::boot(bios).await; - let (profile, bdfs) = os + let (mut profile, bdfs) = os .create_new_profile_with_bdfs(bdfs_mnemonic.into()) .await?; + // Append Mainnet network since initial profile has no network + profile + .networks + .append(ProfileNetwork::new_empty_on(NetworkID::Mainnet)); + os.secure_storage .save_private_hd_factor_source(&bdfs) .await?; @@ -352,6 +411,48 @@ mod tests { assert_eq!(active_profile.unwrap().id(), profile.id()); } + #[actix_rt::test] + async fn test_boot_when_existing_profile_with_no_networks_profile_state_considered_none( + ) { + // ARRANGE + let test_drivers = Drivers::test(); + let bios = Bios::new(test_drivers); + let os = SUT::boot(bios.clone()).await; + let (first_profile, first_bdfs) = + os.create_new_profile_with_bdfs(None).await.unwrap(); + + os.secure_storage + .save_private_hd_factor_source(&first_bdfs) + .await + .unwrap(); + os.secure_storage + .save_profile(&first_profile) + .await + .unwrap(); + os.profile_state_holder + .replace_profile_state_with(ProfileState::Loaded( + first_profile.clone(), + )) + .unwrap(); + + // ACT + let new_os = SUT::boot(bios.clone()).await; + + // ASSERT + assert!(new_os.profile().is_err()); + assert!(new_os + .secure_storage + .load_profile() + .await + .unwrap() + .is_none()); + assert!(new_os + .secure_storage + .load_mnemonic_with_passphrase(&first_bdfs.factor_source.id) + .await + .is_err()) + } + #[actix_rt::test] async fn test_change_log_level() { // ARRANGE (and ACT) @@ -370,7 +471,9 @@ mod tests { #[actix_rt::test] async fn test_new_wallet() { - let os = SUT::fast_boot().await; + let test_drivers = Drivers::test(); + let bios = Bios::new(test_drivers); + let os = SUT::boot(bios).await; os.new_wallet().await.unwrap(); @@ -383,6 +486,8 @@ mod tests { .load_mnemonic_with_passphrase(&bdfs.id) .await .is_ok()); + + assert!(profile.networks.is_empty()); } #[actix_rt::test] @@ -405,9 +510,76 @@ mod tests { assert_ne!(os.profile().unwrap().bdfs(), profile_to_import.bdfs()); } + #[actix_rt::test] + async fn test_new_wallet_through_derived_bdfs() { + let test_drivers = Drivers::test(); + let bios = Bios::new(test_drivers); + let os = SUT::boot(bios).await; + + os.new_wallet_with_derived_bdfs( + PrivateHierarchicalDeterministicFactorSource::sample(), + Accounts::sample_mainnet(), + ) + .await + .unwrap(); + + let profile = os.profile().unwrap(); + + assert!(profile.has_any_account_on_any_network()); + } + + #[actix_rt::test] + async fn test_new_wallet_through_derived_bdfs_with_empty_accounts() { + let test_drivers = Drivers::test(); + let bios = Bios::new(test_drivers); + let os = SUT::boot(bios).await; + + os.new_wallet_with_derived_bdfs( + PrivateHierarchicalDeterministicFactorSource::sample(), + Accounts::new(), + ) + .await + .unwrap(); + + let profile = os.profile().unwrap(); + + assert!(!profile.networks.is_empty()); + } + + #[actix_rt::test] + async fn test_new_wallet_through_derived_bdfs_with_accounts_derived_from_other_hd_factor_source( + ) { + let test_drivers = Drivers::test(); + let bios = Bios::new(test_drivers); + let os = SUT::boot(bios).await; + + let other_hd = + PrivateHierarchicalDeterministicFactorSource::sample_other(); + let invalid_account = Account::new( + other_hd + .derive_entity_creation_factor_instance(NetworkID::Mainnet, 0), + DisplayName::new("Invalid Account").unwrap(), + AppearanceID::sample(), + ); + + let result = os + .new_wallet_with_derived_bdfs( + PrivateHierarchicalDeterministicFactorSource::sample(), + Accounts::just(invalid_account), + ) + .await; + + assert_eq!( + result.unwrap_err(), + CommonError::EntitiesNotDerivedByFactorSource + ) + } + #[actix_rt::test] async fn test_delete_wallet() { - let os = SUT::fast_boot().await; + let test_drivers = Drivers::test(); + let bios = Bios::new(test_drivers); + let os = SUT::boot(bios).await; os.new_wallet().await.unwrap(); let profile = os.profile().unwrap(); let bdfs = profile.bdfs(); diff --git a/crates/sargon/src/system/sargon_os/sargon_os_accounts.rs b/crates/sargon/src/system/sargon_os/sargon_os_accounts.rs index 2c6d72224..a62326e80 100644 --- a/crates/sargon/src/system/sargon_os/sargon_os_accounts.rs +++ b/crates/sargon/src/system/sargon_os/sargon_os_accounts.rs @@ -709,7 +709,8 @@ mod tests { events, vec![ Booted, - ProfileSaved, + ProfileSaved, // Save of initial profile + ProfileSaved, // Save of the new account FactorSourceUpdated, ProfileSaved, AccountAdded diff --git a/crates/sargon/src/system/sargon_os/sargon_os_profile.rs b/crates/sargon/src/system/sargon_os/sargon_os_profile.rs index 3a782acbb..db8dcb763 100644 --- a/crates/sargon/src/system/sargon_os/sargon_os_profile.rs +++ b/crates/sargon/src/system/sargon_os/sargon_os_profile.rs @@ -39,21 +39,28 @@ impl SargonOS { #[uniffi::export] impl SargonOS { pub async fn set_profile(&self, profile: Profile) -> Result<()> { - if profile.id() != self.profile()?.id() { - return Err( - CommonError::TriedToUpdateProfileWithOneWithDifferentID, - ); - } - - self.update_profile_with(|p| { - *p = profile.clone(); - Ok(()) - }) - .await?; + if let Ok(current_profile) = self.profile() { + if current_profile.id() != profile.id() { + return Err( + CommonError::TriedToUpdateProfileWithOneWithDifferentID, + ); + } + + self.update_profile_with(|p| { + *p = profile.clone(); + Ok(()) + }) + .await?; + } else { + self.profile_state_holder + .replace_profile_state_with(ProfileState::Loaded(profile))?; + self.save_existing_profile().await?; + }; + let updated_profile = self.profile()?; self.clients .profile_state_change - .emit(ProfileState::Loaded(profile)) + .emit(ProfileState::Loaded(updated_profile)) .await; Ok(()) @@ -157,7 +164,12 @@ impl SargonOS { let secure_storage = &self.secure_storage; secure_storage - .save(SecureStorageKey::ProfileSnapshot, profile) + .save( + SecureStorageKey::ProfileSnapshot { + profile_id: profile.id(), + }, + profile, + ) .await?; self.event_bus @@ -470,4 +482,18 @@ mod tests { Err(CommonError::TriedToUpdateProfileWithOneWithDifferentID) ); } + + #[actix_rt::test] + async fn test_set_profile_when_no_profile_exists() { + // ARRANGE + let test_drivers = Drivers::test(); + let bios = Bios::new(test_drivers); + let os = SargonOS::boot(bios).await; + + // ACT + let _ = os.with_timeout(|x| x.set_profile(Profile::sample())).await; + + // ASSERT + assert_eq!(os.profile().unwrap(), Profile::sample()); + } } diff --git a/examples/android/build.gradle.kts b/examples/android/build.gradle.kts index b6e9275c0..a7ac215d9 100644 --- a/examples/android/build.gradle.kts +++ b/examples/android/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { implementation(libs.okhttp) implementation(libs.okhttp.logging.interceptor) implementation(libs.hilt.android) + implementation(libs.timber) implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel) diff --git a/examples/android/src/main/java/com/radixdlt/sargon/android/ExampleApplication.kt b/examples/android/src/main/java/com/radixdlt/sargon/android/ExampleApplication.kt index e43163ac0..8c3622445 100644 --- a/examples/android/src/main/java/com/radixdlt/sargon/android/ExampleApplication.kt +++ b/examples/android/src/main/java/com/radixdlt/sargon/android/ExampleApplication.kt @@ -2,6 +2,17 @@ package com.radixdlt.sargon.android import android.app.Application import dagger.hilt.android.HiltAndroidApp +import timber.log.Timber @HiltAndroidApp -class ExampleApplication: Application() \ No newline at end of file +class ExampleApplication : Application() { + + override fun onCreate() { + super.onCreate() + + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + } + +} \ No newline at end of file diff --git a/examples/android/src/main/java/com/radixdlt/sargon/android/MainActivity.kt b/examples/android/src/main/java/com/radixdlt/sargon/android/MainActivity.kt index ffc2c60ec..954baf8d8 100644 --- a/examples/android/src/main/java/com/radixdlt/sargon/android/MainActivity.kt +++ b/examples/android/src/main/java/com/radixdlt/sargon/android/MainActivity.kt @@ -13,11 +13,15 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -30,13 +34,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.radixdlt.sargon.CommonException import com.radixdlt.sargon.FactorSource +import com.radixdlt.sargon.NetworkId import com.radixdlt.sargon.Profile import com.radixdlt.sargon.ProfileState import com.radixdlt.sargon.android.ui.theme.SargonAndroidTheme @@ -49,6 +54,7 @@ import com.radixdlt.sargon.extensions.name import com.radixdlt.sargon.extensions.string import com.radixdlt.sargon.extensions.vendor import com.radixdlt.sargon.extensions.version +import com.radixdlt.sargon.os.SargonOsState import com.radixdlt.sargon.os.driver.BiometricsHandler import com.radixdlt.sargon.samples.sample import dagger.hilt.android.AndroidEntryPoint @@ -90,8 +96,8 @@ fun WalletContent( Column { Text(text = "Sargon App") val status = when (state.sargonState) { - SargonOsManager.SargonState.Idle -> "Idle" - is SargonOsManager.SargonState.Booted -> "Booted" + SargonOsState.Idle -> "Idle" + is SargonOsState.Booted -> "Booted" } Text( text = "OS Status: $status", @@ -115,7 +121,11 @@ fun WalletContent( } } ) { padding -> - Column(modifier = Modifier.padding(padding)) { + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) { state.info?.let { info -> var isHostInfoVisible by remember { mutableStateOf(false) @@ -167,6 +177,12 @@ fun WalletContent( } when (val profileState = state.profileState) { + null -> CircularProgressIndicator( + modifier = Modifier + .padding(32.dp) + .align(Alignment.CenterHorizontally) + ) + is ProfileState.None -> NoProfileContent( modifier = Modifier .padding(16.dp), @@ -188,6 +204,12 @@ fun WalletContent( profile = profileState.v1, onDevModeChanged = { enabled -> viewModel.onDevModeChanged(enabled) + }, + onAccountAdded = { networkId, accountName -> + viewModel.onCreateAccountWithDevice( + networkId = networkId, + accountName = accountName + ) } ) } @@ -253,7 +275,8 @@ private fun IncompatibleProfile( private fun ProfileContent( modifier: Modifier = Modifier, profile: Profile, - onDevModeChanged: (Boolean) -> Unit + onDevModeChanged: (Boolean) -> Unit, + onAccountAdded: (networkId: NetworkId, accountName: String) -> Unit ) { Column( modifier = modifier @@ -344,6 +367,35 @@ private fun ProfileContent( text = "• ${account.displayName.value}" ) } + var newAccountName by remember { + mutableStateOf("") + } + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = newAccountName, + onValueChange = { + newAccountName = it + }, + placeholder = { + Text(text = "New Account name...") + }, + maxLines = 1, + keyboardActions = KeyboardActions( + onDone = { + if (newAccountName.isBlank()) return@KeyboardActions + + onAccountAdded( + network.id, + newAccountName + ) + + newAccountName = "" + } + ), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ) + ) Text( modifier = Modifier.padding(horizontal = 16.dp), diff --git a/examples/android/src/main/java/com/radixdlt/sargon/android/MainViewModel.kt b/examples/android/src/main/java/com/radixdlt/sargon/android/MainViewModel.kt index bd6baecc9..e9ef2888c 100644 --- a/examples/android/src/main/java/com/radixdlt/sargon/android/MainViewModel.kt +++ b/examples/android/src/main/java/com/radixdlt/sargon/android/MainViewModel.kt @@ -3,11 +3,16 @@ package com.radixdlt.sargon.android import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.radixdlt.sargon.Account +import com.radixdlt.sargon.DisplayName import com.radixdlt.sargon.HostId import com.radixdlt.sargon.HostInfo +import com.radixdlt.sargon.NetworkId import com.radixdlt.sargon.Profile import com.radixdlt.sargon.ProfileState import com.radixdlt.sargon.Timestamp +import com.radixdlt.sargon.os.SargonOsManager +import com.radixdlt.sargon.os.SargonOsState +import com.radixdlt.sargon.os.driver.AndroidProfileStateChangeDriver import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted @@ -17,16 +22,18 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import timber.log.Timber import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( - private val sargonOsManager: SargonOsManager + private val sargonOsManager: SargonOsManager, + private val profileStateChangeDriver: AndroidProfileStateChangeDriver ) : ViewModel() { val state = combine( sargonOsManager.sargonState, - sargonOsManager.profileState, + profileStateChangeDriver.profileState, ) { sargonState, profileState -> State( sargonState, @@ -34,7 +41,7 @@ class MainViewModel @Inject constructor( ) }.map { state -> if (state.info == null) { - val osState = state.sargonState as? SargonOsManager.SargonState.Booted ?: return@map state + val osState = state.sargonState as? SargonOsState.Booted ?: return@map state state.copy( info = HostInformation( @@ -53,35 +60,59 @@ class MainViewModel @Inject constructor( fun onCreateNewWallet() = viewModelScope.launch { withContext(Dispatchers.Default) { - val os = sargonOsManager.sargonOs.first() + val os = sargonOsManager.sargonOs runCatching { os.newWallet() + }.onFailure { error -> + Timber.tag("sargon app").w(error) } } } fun onImportWallet(profile: Profile) = viewModelScope.launch { withContext(Dispatchers.Default) { - val os = sargonOsManager.sargonOs.first() + val os = sargonOsManager.sargonOs runCatching { os.importWallet(profile = profile, bdfsSkipped = true) + }.onFailure { error -> + Timber.tag("sargon app").w(error) } } } fun onDeleteWallet() = viewModelScope.launch { withContext(Dispatchers.Default) { - val os = sargonOsManager.sargonOs.first() + val os = sargonOsManager.sargonOs runCatching { os.deleteWallet() + }.onFailure { error -> + Timber.tag("sargon app").w(error) + } + } + } + + fun onCreateAccountWithDevice( + networkId: NetworkId, + accountName: String + ) = viewModelScope.launch { + withContext(Dispatchers.Default) { + val os = sargonOsManager.sargonOs + + runCatching { + os.createAndSaveNewAccount( + networkId = networkId, + name = DisplayName(accountName) + ) + }.onFailure { error -> + Timber.tag("sargon app").w(error) } } } fun onDevModeChanged(enabled: Boolean) = viewModelScope.launch { withContext(Dispatchers.Default) { - val os = sargonOsManager.sargonOs.first() - val profile = sargonOsManager.profile.first() + val os = sargonOsManager.sargonOs + val profile = profileStateChangeDriver.profile.first() runCatching { os.setProfile(profile.mutate { @@ -108,8 +139,8 @@ class MainViewModel @Inject constructor( } data class State( - val sargonState: SargonOsManager.SargonState = SargonOsManager.SargonState.Idle, - val profileState: ProfileState = ProfileState.None, + val sargonState: SargonOsState = SargonOsState.Idle, + val profileState: ProfileState? = null, val accounts: List = emptyList(), val info: HostInformation? = null ) diff --git a/examples/android/src/main/java/com/radixdlt/sargon/android/SargonOsManager.kt b/examples/android/src/main/java/com/radixdlt/sargon/android/SargonOsManager.kt deleted file mode 100644 index fd0b0b552..000000000 --- a/examples/android/src/main/java/com/radixdlt/sargon/android/SargonOsManager.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.radixdlt.sargon.android - -import com.radixdlt.sargon.Bios -import com.radixdlt.sargon.Profile -import com.radixdlt.sargon.ProfileState -import com.radixdlt.sargon.SargonOs -import com.radixdlt.sargon.android.di.ApplicationScope -import com.radixdlt.sargon.os.driver.AndroidProfileStateChangeDriver -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class SargonOsManager @Inject constructor( - private val bios: Bios, - private val profileStateChangeDriver: AndroidProfileStateChangeDriver, - @ApplicationScope private val applicationScope: CoroutineScope -) { - - private val _sargon_state = MutableStateFlow(SargonState.Idle) - - val sargonState: StateFlow - get() = _sargon_state.asStateFlow() - - val sargonOs: Flow = _sargon_state - .filterIsInstance() - .map { it.os } - - val profileState: Flow = profileStateChangeDriver.profileState - val profile: Flow = profileStateChangeDriver.profile - - init { - boot() - } - - private fun boot() = applicationScope.launch { - if (_sargon_state.value is SargonState.Booted) { - return@launch - } - - withContext(Dispatchers.Default) { - val os = SargonOs.boot(bios) - _sargon_state.update { SargonState.Booted(os) } - } - } - - sealed interface SargonState { - data object Idle: SargonState - data class Booted( - val os: SargonOs - ): SargonState - } - -} \ No newline at end of file diff --git a/examples/android/src/main/java/com/radixdlt/sargon/android/di/ApplicationModule.kt b/examples/android/src/main/java/com/radixdlt/sargon/android/di/ApplicationModule.kt index 2e224a36e..6fe1820c7 100644 --- a/examples/android/src/main/java/com/radixdlt/sargon/android/di/ApplicationModule.kt +++ b/examples/android/src/main/java/com/radixdlt/sargon/android/di/ApplicationModule.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore import com.radixdlt.sargon.Bios import com.radixdlt.sargon.android.BuildConfig +import com.radixdlt.sargon.os.SargonOsManager import com.radixdlt.sargon.os.driver.AndroidEventBusDriver import com.radixdlt.sargon.os.driver.AndroidProfileStateChangeDriver import com.radixdlt.sargon.os.driver.BiometricsHandler @@ -131,12 +132,12 @@ object ApplicationModule { @Provides @Singleton - fun provideEventBusDriver(): AndroidEventBusDriver = AndroidEventBusDriver() + fun provideEventBusDriver(): AndroidEventBusDriver = AndroidEventBusDriver @Provides @Singleton fun provideProfileStateChangeDriver(): AndroidProfileStateChangeDriver = - AndroidProfileStateChangeDriver() + AndroidProfileStateChangeDriver @Provides @Singleton @@ -151,7 +152,6 @@ object ApplicationModule { @DeviceInfoPreferences deviceInfoPreferences: DataStore, ): Bios = Bios.from( context = context, - enableLogging = BuildConfig.DEBUG, httpClient = httpClient, biometricsHandler = biometricsHandler, encryptedPreferencesDataStore = encryptedPreferences, @@ -160,4 +160,16 @@ object ApplicationModule { eventBusDriver = eventBusDriver, profileStateChangeDriver = profileStateChangeDriver ) + + @Provides + @Singleton + fun provideSargonOsManager( + bios: Bios, + @ApplicationScope applicationScope: CoroutineScope, + @DefaultDispatcher dispatcher: CoroutineDispatcher + ): SargonOsManager = SargonOsManager.factory( + bios = bios, + applicationScope = applicationScope, + defaultDispatcher = dispatcher + ) } \ No newline at end of file diff --git a/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidStorageDriverDeviceFactorSourceMnemonicTest.kt b/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidStorageDriverDeviceFactorSourceMnemonicTest.kt index f3b2a8c85..3bff4ff62 100644 --- a/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidStorageDriverDeviceFactorSourceMnemonicTest.kt +++ b/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidStorageDriverDeviceFactorSourceMnemonicTest.kt @@ -1,7 +1,7 @@ package com.radixdlt.sargon.os.driver import android.content.Context -import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest @@ -14,7 +14,6 @@ import com.radixdlt.sargon.SecureStorageKey import com.radixdlt.sargon.mnemonicWithPassphraseToJsonBytes import com.radixdlt.sargon.newMnemonicWithPassphraseFromJsonBytes import com.radixdlt.sargon.os.driver.AndroidStorageDriverTest.Companion.sut -import com.radixdlt.sargon.os.driver.BiometricsFailure.AuthenticationNotPossible import com.radixdlt.sargon.os.storage.EncryptionHelper import com.radixdlt.sargon.samples.sample import io.mockk.every @@ -48,7 +47,10 @@ class AndroidStorageDriverDeviceFactorSourceMnemonicTest { context = testContext, scope = backgroundScope, onAuthorize = { - Result.failure(AuthenticationNotPossible(BiometricManager.BIOMETRIC_STATUS_UNKNOWN)) + Result.failure(BiometricsFailure( + errorCode = BiometricPrompt.ERROR_USER_CANCELED, + errorMessage = "The user cancelled." + )) } ) @@ -66,7 +68,7 @@ class AndroidStorageDriverDeviceFactorSourceMnemonicTest { }.onFailure { error -> assertTrue( "Expected CommonException.SecureStorageWriteException but got $error", - error is CommonException.SecureStorageWriteException + error is CommonException.SecureStorageAccessException ) }.onSuccess { error("Save operation did not throw when it should.") @@ -118,7 +120,12 @@ class AndroidStorageDriverDeviceFactorSourceMnemonicTest { Result.success(Unit) } else { mockUnauthorize() - Result.failure(AuthenticationNotPossible(BiometricManager.BIOMETRIC_STATUS_UNKNOWN)) + Result.failure( + BiometricsFailure( + errorCode = BiometricPrompt.ERROR_USER_CANCELED, + errorMessage = "The user cancelled" + ) + ) } } ) diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/SecureStorageAccessErrorKind.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/SecureStorageAccessErrorKind.kt new file mode 100644 index 000000000..c771daca3 --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/SecureStorageAccessErrorKind.kt @@ -0,0 +1,7 @@ +package com.radixdlt.sargon.extensions + +import com.radixdlt.sargon.SecureStorageAccessErrorKind +import com.radixdlt.sargon.secureStorageAccessErrorKindIsManualCancellation + +fun SecureStorageAccessErrorKind.isManualCancellation() = + secureStorageAccessErrorKindIsManualCancellation(kind = this) \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/Bios.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/Bios.kt index da6d70738..17ac02903 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/Bios.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/Bios.kt @@ -24,14 +24,13 @@ import timber.log.Timber @KoverIgnore fun Bios.Companion.from( context: Context, - enableLogging: Boolean, httpClient: OkHttpClient, biometricsHandler: BiometricsHandler, encryptedPreferencesDataStore: DataStore, preferencesDatastore: DataStore, deviceInfoDatastore: DataStore, - eventBusDriver: EventBusDriver, - profileStateChangeDriver: ProfileStateChangeDriver + eventBusDriver: AndroidEventBusDriver, + profileStateChangeDriver: AndroidProfileStateChangeDriver, ): Bios { val storageDriver = AndroidStorageDriver( biometricAuthorizationDriver = AndroidBiometricAuthorizationDriver( @@ -48,7 +47,7 @@ fun Bios.Companion.from( unsafeStorage = storageDriver, entropyProvider = AndroidEntropyProviderDriver(), hostInfo = AndroidHostInfoDriver(context), - logging = AndroidLoggingDriver(enableLogging), + logging = AndroidLoggingDriver(), eventBus = eventBusDriver, fileSystem = AndroidFileSystemDriver(context), profileStateChangeDriver = profileStateChangeDriver diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/SargonOsManager.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/SargonOsManager.kt new file mode 100644 index 000000000..788e49018 --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/SargonOsManager.kt @@ -0,0 +1,57 @@ +package com.radixdlt.sargon.os + +import com.radixdlt.sargon.Bios +import com.radixdlt.sargon.SargonOs +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class SargonOsManager internal constructor( + bios: Bios, + applicationScope: CoroutineScope, + defaultDispatcher: CoroutineDispatcher +) { + private val _sargonState = MutableStateFlow(SargonOsState.Idle) + + val sargonState: StateFlow = _sargonState.asStateFlow() + val sargonOs: SargonOs + get() = (sargonState.value as? SargonOsState.Booted)?.os ?: throw SargonOsNotBooted() + + init { + applicationScope.launch { + withContext(defaultDispatcher) { + val os = SargonOs.boot(bios) + _sargonState.update { SargonOsState.Booted(os) } + } + } + } + + companion object { + @Volatile + private var instance: SargonOsManager? = null + + fun factory( + bios: Bios, + applicationScope: CoroutineScope, + defaultDispatcher: CoroutineDispatcher + ): SargonOsManager = instance ?: synchronized(this) { + instance ?: SargonOsManager(bios, applicationScope, defaultDispatcher).also { + instance = it + } + } + } +} + +sealed interface SargonOsState { + data object Idle : SargonOsState + data class Booted( + val os: SargonOs + ) : SargonOsState +} + +class SargonOsNotBooted : IllegalStateException("Sargon OS is not booted") \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidBiometricAuthorizationDriver.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidBiometricAuthorizationDriver.kt index 83df56e66..d28674ba0 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidBiometricAuthorizationDriver.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidBiometricAuthorizationDriver.kt @@ -3,19 +3,18 @@ package com.radixdlt.sargon.os.driver import android.os.Build import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt +import androidx.biometric.BiometricPrompt.AuthenticationError import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.radixdlt.sargon.annotation.KoverIgnore import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout import timber.log.Timber import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @@ -26,49 +25,11 @@ internal interface BiometricAuthorizationDriver { } -sealed class BiometricsFailure(override val message: String?) : Exception() { - - data class AuthenticationNotPossible( - val authenticationStatus: Int - ) : BiometricsFailure( - message = "Biometrics failed to request. canAuthenticate() returned [$authenticationStatus] ${authenticationStatus.toAuthenticationStatusMessage()}" - ) - - data class AuthenticationError( - val errorCode: Int, - val errorMessage: String - ) : BiometricsFailure( - message = "User did not authorize. Received [$errorCode]: $errorMessage" - ) - - companion object { - private fun Int.toAuthenticationStatusMessage(): String = when (this) { - BiometricManager.BIOMETRIC_SUCCESS -> - "The user can successfully authenticate." - - BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> - "Unable to determine whether the user can authenticate." - - BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> - "The user can't authenticate because the specified options are incompatible with the current Android version." - - BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> - "The user can't authenticate because the hardware is unavailable. Try again later." - - BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> - "The user can't authenticate because no biometric or device credential is enrolled." - - BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> - "The user can't authenticate because there is no suitable hardware (e. g. no biometric sensor or no keyguard)." - - BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> - "The user can't authenticate because a security vulnerability has been discovered with one or more hardware sensors. The affected sensor(s) are unavailable until a security update has addressed the issue." - - else -> "" - } - } - -} +internal class BiometricsFailure( + @AuthenticationError + val errorCode: Int, + val errorMessage: String? +) : Exception("[$errorCode] $errorMessage") internal class AndroidBiometricAuthorizationDriver( private val biometricsHandler: BiometricsHandler @@ -87,24 +48,24 @@ class BiometricsHandler( private val biometricsResultsChannel = Channel>() fun register(activity: FragmentActivity) { - activity.lifecycleScope.launch { - // Listen to biometric prompt requests while the activity is at least started. - activity.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - biometricRequestsChannel.receiveAsFlow().collectLatest { - val result = requestBiometricsAuthorization(activity) - - // Send back the result to sargon os - biometricsResultsChannel.send(result) - } + biometricRequestsChannel + .receiveAsFlow() + .flowWithLifecycle( + lifecycle = activity.lifecycle, + minActiveState = Lifecycle.State.STARTED + ) + .onEach { + val result = requestBiometricsAuthorization(activity) + + // Send back the result to sargon os + biometricsResultsChannel.send(result) } - } + .launchIn(activity.lifecycleScope) } internal suspend fun askForBiometrics(): Result { - // Suspend until an activity is subscribed to this channel - withTimeout(5000) { - biometricRequestsChannel.send(Unit) - } + // Suspend until an activity is subscribed to this channel and is at least started + biometricRequestsChannel.send(Unit) // If an activity is already registered, then we need to wait until the user provides // the response from the biometrics prompt @@ -115,19 +76,6 @@ class BiometricsHandler( activity: FragmentActivity ): Result = withContext(Dispatchers.Main) { suspendCoroutine { continuation -> - val biometricManager = BiometricManager.from(activity) - - val authenticationPreCheckStatus = - biometricManager.canAuthenticate(allowedAuthenticators) - if (authenticationPreCheckStatus != BiometricManager.BIOMETRIC_SUCCESS) { - continuation.resume( - Result.failure( - BiometricsFailure.AuthenticationNotPossible(authenticationPreCheckStatus) - ) - ) - return@suspendCoroutine - } - val authCallback = object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { continuation.resume(Result.success(Unit)) @@ -135,12 +83,7 @@ class BiometricsHandler( override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { continuation.resume( - Result.failure( - BiometricsFailure.AuthenticationError( - errorCode, - errString.toString() - ) - ) + Result.failure(BiometricsFailure(errorCode, errString.toString())) ) } diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidEventBusDriver.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidEventBusDriver.kt index 60ae775a0..0d2aa2cff 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidEventBusDriver.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidEventBusDriver.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -class AndroidEventBusDriver: EventBusDriver { +object AndroidEventBusDriver: EventBusDriver { private val _events = MutableSharedFlow() val events: Flow = _events.asSharedFlow() diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidLoggingDriver.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidLoggingDriver.kt index 433097733..85519201f 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidLoggingDriver.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidLoggingDriver.kt @@ -4,15 +4,7 @@ import com.radixdlt.sargon.LogLevel import com.radixdlt.sargon.LoggingDriver import timber.log.Timber -class AndroidLoggingDriver( - isLoggingEnabled: Boolean -): LoggingDriver { - - init { - if (isLoggingEnabled) { - Timber.plant(Timber.DebugTree()) - } - } +class AndroidLoggingDriver: LoggingDriver { override fun log(level: LogLevel, msg: String) { val logger = Timber.tag("sargon") diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidProfileStateChangeDriver.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidProfileStateChangeDriver.kt index 040c825cd..ca5db96e6 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidProfileStateChangeDriver.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidProfileStateChangeDriver.kt @@ -9,17 +9,18 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update -class AndroidProfileStateChangeDriver : ProfileStateChangeDriver { +object AndroidProfileStateChangeDriver : ProfileStateChangeDriver { - private val _profileState = MutableStateFlow(ProfileState.None) + private val _profileState: MutableStateFlow = MutableStateFlow(null) - val profileState: StateFlow = _profileState.asStateFlow() - val profile: Flow = profileState.filterIsInstance().map { - it.v1 - } + val profileState: StateFlow = _profileState.asStateFlow() + val profile: Flow = _profileState + .filterIsInstance() + .map { it.v1 } override suspend fun handleProfileStateChange(changedProfileState: ProfileState) { - _profileState.emit(changedProfileState) + _profileState.update { changedProfileState } } } \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidStorageDriver.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidStorageDriver.kt index e35fdb05e..abe18ac6a 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidStorageDriver.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidStorageDriver.kt @@ -1,9 +1,11 @@ package com.radixdlt.sargon.os.driver +import androidx.biometric.BiometricPrompt import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import com.radixdlt.sargon.BagOfBytes import com.radixdlt.sargon.CommonException +import com.radixdlt.sargon.SecureStorageAccessErrorKind import com.radixdlt.sargon.SecureStorageDriver import com.radixdlt.sargon.SecureStorageKey import com.radixdlt.sargon.UnsafeStorageDriver @@ -13,7 +15,6 @@ import com.radixdlt.sargon.os.storage.key.ByteArrayKeyMapping import com.radixdlt.sargon.os.storage.key.DeviceFactorSourceMnemonicKeyMapping import com.radixdlt.sargon.os.storage.key.HostIdKeyMapping import com.radixdlt.sargon.os.storage.key.ProfileSnapshotKeyMapping -import timber.log.Timber internal class AndroidStorageDriver( private val biometricAuthorizationDriver: BiometricAuthorizationDriver, @@ -25,60 +26,41 @@ internal class AndroidStorageDriver( override suspend fun loadData(key: SecureStorageKey): BagOfBytes? = key .mapping() .then { it.read() } - .reportFailure( - "Failed to load data for $key", - CommonException.SecureStorageReadException() - ) + .reportSecureStorageReadFailure(key = key) .getOrNull() override suspend fun saveData(key: SecureStorageKey, data: BagOfBytes) { key.mapping() .then { it.write(data) } - .reportFailure( - "Failed to save data for $key", - CommonException.SecureStorageWriteException() - ) + .reportSecureStorageWriteFailure(key = key) } override suspend fun deleteDataForKey(key: SecureStorageKey) { key.mapping() .then { it.remove() } - .reportFailure( - "Failed to remove data for $key", - CommonException.SecureStorageWriteException() - ) + .reportSecureStorageWriteFailure(key = key) } override suspend fun loadData(key: UnsafeStorageKey): BagOfBytes? = key .mapping() .then { it.read() } - .reportFailure( - "Failed to load data for $key", - CommonException.UnsafeStorageReadException() - ) + .reportUnsafeStorageReadFailure() .getOrNull() override suspend fun saveData(key: UnsafeStorageKey, data: BagOfBytes) { key.mapping() .then { it.write(data) } - .reportFailure( - "Failed to save data for $key", - CommonException.UnsafeStorageWriteException() - ) + .reportUnsafeStorageWriteFailure() } override suspend fun deleteDataForKey(key: UnsafeStorageKey) { key.mapping() .then { it.remove() } - .reportFailure( - "Failed to remove data for $key", - CommonException.UnsafeStorageWriteException() - ) + .reportUnsafeStorageWriteFailure() } private fun SecureStorageKey.mapping() = when (this) { is SecureStorageKey.ProfileSnapshot -> ProfileSnapshotKeyMapping( - key = this, encryptedStorage = encryptedPreferencesDatastore ) @@ -105,12 +87,58 @@ internal class AndroidStorageDriver( Result.success(mapping) } - private fun Result.reportFailure(message: String, commonError: CommonException) = - onFailure { error -> - Timber.tag("Sargon").w(error, message) - when (error) { - is CommonException -> throw error - else -> throw commonError - } + private fun Result.reportSecureStorageReadFailure( + key: SecureStorageKey + ) = onFailure { error -> + throw when (error) { + is BiometricsFailure -> error.toCommonException(key) + is CommonException -> error + else -> CommonException.SecureStorageReadException() + } + } + + private fun Result.reportSecureStorageWriteFailure( + key: SecureStorageKey + ) = onFailure { error -> + throw when (error) { + is BiometricsFailure -> error.toCommonException(key) + is CommonException -> error + else -> CommonException.SecureStorageWriteException() + } + } + + private fun Result.reportUnsafeStorageReadFailure() = onFailure { error -> + throw when (error) { + is CommonException -> error + else -> CommonException.UnsafeStorageReadException() } + } + + private fun Result.reportUnsafeStorageWriteFailure() = onFailure { error -> + throw when (error) { + is CommonException -> error + else -> CommonException.UnsafeStorageWriteException() + } + } + + private fun BiometricsFailure.toCommonException(key: SecureStorageKey) = CommonException.SecureStorageAccessException( + key = key, + errorKind = when (errorCode) { + BiometricPrompt.ERROR_CANCELED -> SecureStorageAccessErrorKind.CANCELLED + BiometricPrompt.ERROR_HW_NOT_PRESENT -> SecureStorageAccessErrorKind.HARDWARE_NOT_PRESENT + BiometricPrompt.ERROR_HW_UNAVAILABLE -> SecureStorageAccessErrorKind.HARDWARE_UNAVAILABLE + BiometricPrompt.ERROR_LOCKOUT -> SecureStorageAccessErrorKind.LOCKOUT + BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> SecureStorageAccessErrorKind.LOCKOUT_PERMANENT + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> SecureStorageAccessErrorKind.NEGATIVE_BUTTON + BiometricPrompt.ERROR_NO_BIOMETRICS -> SecureStorageAccessErrorKind.NO_BIOMETRICS + BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> SecureStorageAccessErrorKind.NO_DEVICE_CREDENTIAL + BiometricPrompt.ERROR_NO_SPACE -> SecureStorageAccessErrorKind.NO_SPACE + BiometricPrompt.ERROR_TIMEOUT -> SecureStorageAccessErrorKind.TIMEOUT + BiometricPrompt.ERROR_UNABLE_TO_PROCESS -> SecureStorageAccessErrorKind.UNABLE_TO_PROCESS + BiometricPrompt.ERROR_USER_CANCELED -> SecureStorageAccessErrorKind.USER_CANCELLED + BiometricPrompt.ERROR_VENDOR -> SecureStorageAccessErrorKind.VENDOR + else -> throw CommonException.Unknown() + }, + errorMessage = errorMessage.orEmpty() + ) } \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/EncryptionHelper.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/EncryptionHelper.kt index bd89c5986..0e89f96c8 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/EncryptionHelper.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/EncryptionHelper.kt @@ -2,6 +2,7 @@ package com.radixdlt.sargon.os.storage +import android.util.Base64 import com.radixdlt.sargon.extensions.then import java.nio.ByteBuffer import java.nio.charset.StandardCharsets @@ -11,7 +12,6 @@ import javax.crypto.Cipher import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec -import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi internal object EncryptionHelper { @@ -64,12 +64,12 @@ internal object EncryptionHelper { @Suppress("UNCHECKED_CAST") internal fun T.encrypt(secretKey: SecretKey): Result = runCatching { when (this) { - is String -> Base64.encode( - source = EncryptionHelper.encrypt( - input = toByteArray(), - secretKey = secretKey - ).getOrThrow() - ) as T + is String -> EncryptionHelper.encrypt( + input = toByteArray(), + secretKey = secretKey + ).mapCatching { + Base64.encodeToString(it, Base64.DEFAULT) as T + }.getOrThrow() is ByteArray -> EncryptionHelper.encrypt(input = this, secretKey = secretKey) .getOrThrow() as T @@ -91,7 +91,10 @@ internal fun T.decrypt(secretKey: SecretKey): Result = runCatching when (this) { is String -> String( EncryptionHelper.decrypt( - input = Base64.decode(source = this), + input = Base64.decode( + this, + Base64.DEFAULT + ), secretKey = secretKey ).getOrThrow(), StandardCharsets.UTF_8 @@ -112,7 +115,7 @@ internal fun T.decrypt(secretKey: SecretKey): Result = runCatching * The receiver must be either a [String] or a [ByteArray]. Other types are not supported as * of this moment. */ -internal fun T.encrypt(keySpec: KeySpec) = keySpec.getOrGenerateSecretKey() +fun T.encrypt(keySpec: KeySpec) = keySpec.getOrGenerateSecretKey() .then { encrypt(secretKey = it) } /** @@ -121,7 +124,7 @@ internal fun T.encrypt(keySpec: KeySpec) = keySpec.getOrGenerateSecret * The receiver must be either a [String] or a [ByteArray]. Other types are not supported as * of this moment. */ -internal fun T.decrypt(keySpec: KeySpec) = keySpec.getOrGenerateSecretKey() +fun T.decrypt(keySpec: KeySpec) = keySpec.getOrGenerateSecretKey() .then { decrypt(secretKey = it) } /** @@ -130,7 +133,7 @@ internal fun T.decrypt(keySpec: KeySpec) = keySpec.getOrGenerateSecret * The receiver must be either a [String] or a [ByteArray]. Other types are not supported as * of this moment. */ -internal fun T.encrypt(encryptionKey: ByteArray) = encrypt( +fun T.encrypt(encryptionKey: ByteArray) = encrypt( secretKey = SecretKeySpec(encryptionKey, EncryptionHelper.AES_ALGORITHM) ) @@ -140,7 +143,7 @@ internal fun T.encrypt(encryptionKey: ByteArray) = encrypt( * The receiver must be either a [String] or a [ByteArray]. Other types are not supported as * of this moment. */ -internal fun T.decrypt(encryptionKey: ByteArray) = decrypt( +fun T.decrypt(encryptionKey: ByteArray) = decrypt( secretKey = SecretKeySpec(encryptionKey, EncryptionHelper.AES_ALGORITHM) ) diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/KeySpec.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/KeySpec.kt index 1e3206239..8b95f03e6 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/KeySpec.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/KeySpec.kt @@ -4,8 +4,11 @@ import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyPermanentlyInvalidatedException import android.security.keystore.KeyProperties +import com.radixdlt.sargon.Uuid import com.radixdlt.sargon.annotation.KoverIgnore +import timber.log.Timber import java.security.KeyStore +import java.security.KeyStoreException import java.security.ProviderException import javax.crypto.KeyGenerator import javax.crypto.SecretKey @@ -15,7 +18,7 @@ import javax.crypto.SecretKey * operations. [requestAuthorization] is only invoked for [KeySpec]s that are defined with * [KeyGenParameterSpec#Builder#setUserAuthenticationRequired] to true */ -internal sealed interface KeystoreAccessRequest { +sealed interface KeystoreAccessRequest { val keySpec: KeySpec @@ -33,6 +36,12 @@ internal sealed interface KeystoreAccessRequest { override suspend fun requestAuthorization(): Result = Result.success(Unit) } + data class ForCache(private val alias: String): KeystoreAccessRequest { + override val keySpec: KeySpec = KeySpec.Cache(alias) + + override suspend fun requestAuthorization(): Result = Result.success(Unit) + } + data class ForMnemonic( private val onRequestAuthorization: suspend () -> Result ): KeystoreAccessRequest { @@ -47,7 +56,7 @@ internal sealed interface KeystoreAccessRequest { * The description of the key that describes for cryptographic operations on keystore. */ @KoverIgnore -internal sealed class KeySpec(val alias: String) { +sealed class KeySpec(val alias: String) { /** * The implementation of these methods are heavily based on this: @@ -88,13 +97,13 @@ internal sealed class KeySpec(val alias: String) { .setAuthenticationRequired(authenticationTimeout = authenticationTimeoutSeconds) .build() - fun checkIfPermanentlyInvalidated(input: String): Boolean { + fun checkIfPermanentlyInvalidated(): Boolean { // on pixel 6 pro when lock screen is removed, key entry for an alias is null val secretKeyResult = getSecretKey() if (secretKeyResult.isFailure || secretKeyResult.getOrNull() == null) return true val secretKey = requireNotNull(secretKeyResult.getOrNull()) - val result = input.encrypt(secretKey = secretKey) + val result = Uuid.randomUUID().toString().encrypt(secretKey = secretKey) // according to documentation this is exception that should be thrown if we try to use // invalidated key, but behavior I saw when removing lock screen is that key is // automatically deleted from the keystore @@ -102,6 +111,12 @@ internal sealed class KeySpec(val alias: String) { } } + @KoverIgnore + class Cache(alias: String): KeySpec(alias) { + override fun generateSecretKey(): Result = AesKeyGeneratorBuilder(alias = alias) + .build() + } + @KoverIgnore private data class AesKeyGeneratorBuilder( private val alias: String, @@ -172,5 +187,35 @@ internal sealed class KeySpec(val alias: String) { private const val KEY_ALIAS_PROFILE = "EncryptedProfileAlias" private const val KEY_ALIAS_MNEMONIC = "EncryptedMnemonicAlias" private const val KEY_ALIAS_RADIX_CONNECT = "EncryptedRadixConnectSessionAlias" + + /** + * Resets the given [keySpecs] from [KeyStore] + * + * This usually deletes the entry from [KeyStore]. + * + * In android devices <= 30 we noticed that keys associated to device credentials such + * as [KEY_ALIAS_MNEMONIC] throw [KeyStoreException] when [KeyStore.deleteEntry] is called, + * only when the user resets their device credentials to new ones. + * + * This made it impossible to associate the same key alias to the new device credentials, + * resulting to all encrypt/decrypt methods failing. In such cases, the only possible + * solution is to regenerate a new key with the same alias and associate it with the new + * device credentials + */ + fun reset(keySpecs: List): Result = runCatching { + val keyStore = KeyStore.getInstance(PROVIDER).apply { load(null) } + keySpecs.forEach { + try { + if (keyStore.containsAlias(it.alias)) { + keyStore.deleteEntry(it.alias) + Timber.tag("sargon").w("Key spec ${it.alias} deleted successfully") + } + } catch (_: KeyStoreException) { + Timber.tag("sargon").w("Deleting key spec ${it.alias} failed. Generating a new one...") + // In cases like these the only option is to regenerate the same key + it.generateSecretKey().getOrThrow() + } + } + } } } \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/StorageUtils.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/StorageUtils.kt index b7ec574ef..6c7ba1f86 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/StorageUtils.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/StorageUtils.kt @@ -21,7 +21,7 @@ private suspend fun KeystoreAccessRequest?.requestAuthorizationIfNeeded() = * Reads the contents associated with the given [key] from the data store. * If a [KeystoreAccessRequest] is provided then the data written will be decrypted using keystore */ -internal suspend fun DataStore.read( +suspend fun DataStore.read( key: Preferences.Key, keystoreAccessRequest: KeystoreAccessRequest? = null, retryWhen: suspend ((Throwable, Long) -> Boolean) = { _, _ -> false } @@ -44,7 +44,7 @@ internal suspend fun DataStore.read( * Associates the [value] with the given [key] to the data store. * If a [KeystoreAccessRequest] is provided then the data will be encrypted using keystore */ -internal suspend fun DataStore.write( +suspend fun DataStore.write( key: Preferences.Key, value: T, keystoreAccessRequest: KeystoreAccessRequest? = null @@ -60,7 +60,7 @@ internal suspend fun DataStore.write( } }.toUnit() -internal suspend fun DataStore.remove(key: Preferences.Key) = runCatching { +suspend fun DataStore.remove(key: Preferences.Key) = runCatching { edit { preferences -> preferences.remove(key) } diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/ProfileSnapshotKeyMapping.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/ProfileSnapshotKeyMapping.kt index 8466ab3d4..21bc7f685 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/ProfileSnapshotKeyMapping.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/ProfileSnapshotKeyMapping.kt @@ -16,7 +16,6 @@ import kotlinx.coroutines.delay import java.io.IOException internal class ProfileSnapshotKeyMapping( - private val key: SecureStorageKey.ProfileSnapshot, private val encryptedStorage: DataStore ) : DatastoreKeyMapping { diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/SecureStorageAccessErrorKindTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/SecureStorageAccessErrorKindTest.kt new file mode 100644 index 000000000..d1edd124e --- /dev/null +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/SecureStorageAccessErrorKindTest.kt @@ -0,0 +1,19 @@ +package com.radixdlt.sargon + +import com.radixdlt.sargon.extensions.isManualCancellation +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SecureStorageAccessErrorKindTest { + + @Test + fun testIsManualCancellation() { + SecureStorageAccessErrorKind.entries.forEach { + assertEquals( + it.isManualCancellation(), + secureStorageAccessErrorKindIsManualCancellation(it) + ) + } + } + +} \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/SecureStorageKeyTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/SecureStorageKeyTest.kt index 8ddc800fe..3dfc06594 100644 --- a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/SecureStorageKeyTest.kt +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/SecureStorageKeyTest.kt @@ -23,7 +23,7 @@ class SecureStorageKeyTest { assertEquals( "secure_storage_key_profile_snapshot", - SecureStorageKey.ProfileSnapshot.identifier + SecureStorageKey.ProfileSnapshot(profileId = newProfileIdSample()).identifier ) } diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/SargonOsManagerTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/SargonOsManagerTest.kt new file mode 100644 index 000000000..70de56875 --- /dev/null +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/SargonOsManagerTest.kt @@ -0,0 +1,74 @@ +package com.radixdlt.sargon.os + +import app.cash.turbine.test +import com.radixdlt.sargon.Bios +import com.radixdlt.sargon.Drivers +import com.radixdlt.sargon.SargonOs +import com.radixdlt.sargon.os.driver.AndroidEntropyProviderDriver +import com.radixdlt.sargon.os.driver.AndroidEventBusDriver +import com.radixdlt.sargon.os.driver.AndroidNetworkingDriver +import com.radixdlt.sargon.os.driver.AndroidProfileStateChangeDriver +import com.radixdlt.sargon.os.driver.FakeFileSystemDriver +import com.radixdlt.sargon.os.driver.FakeHostInfoDriver +import com.radixdlt.sargon.os.driver.FakeLoggingDriver +import com.radixdlt.sargon.os.driver.FakeSecureStorageDriver +import com.radixdlt.sargon.os.driver.FakeUnsafeStorageDriver +import io.mockk.mockk +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class SargonOsManagerTest { + private val okHttpClient = mockk() + private val eventBusDriver = AndroidEventBusDriver + private val profileStateChangeDriver = AndroidProfileStateChangeDriver + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher + Job()) + + @Test + fun testBoot() = runTest(testDispatcher) { + val manager = SargonOsManager.factory( + bios = bios(), + applicationScope = testScope, + defaultDispatcher = testDispatcher + ) + + manager.sargonState.test { + assert(awaitItem() is SargonOsState.Idle) + assertThrows { + manager.sargonOs + } + + assert(awaitItem() is SargonOsState.Booted) + assertInstanceOf(SargonOs::class.java, manager.sargonOs) + } + + val newManager = SargonOsManager.factory( + bios = bios(), + applicationScope = testScope, + defaultDispatcher = testDispatcher + ) + assertEquals(newManager, manager) + } + + private fun bios() = Bios( + drivers = Drivers( + networking = AndroidNetworkingDriver(client = okHttpClient), + secureStorage = FakeSecureStorageDriver(), + unsafeStorage = FakeUnsafeStorageDriver(), + entropyProvider = AndroidEntropyProviderDriver(), + hostInfo = FakeHostInfoDriver(), + logging = FakeLoggingDriver(), + eventBus = eventBusDriver, + fileSystem = FakeFileSystemDriver(), + profileStateChangeDriver = profileStateChangeDriver + ) + ) +} \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidEventBusDriverTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidEventBusDriverTest.kt index 66963d8b8..03ec82ae0 100644 --- a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidEventBusDriverTest.kt +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidEventBusDriverTest.kt @@ -14,7 +14,7 @@ import org.junit.jupiter.api.Test class AndroidEventBusDriverTest { - private val sut = AndroidEventBusDriver() + private val sut = AndroidEventBusDriver @Test fun testProfileIsEmitted() = runTest { diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidLoggingDriverTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidLoggingDriverTest.kt index b7bc3dbfa..c8ab79815 100644 --- a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidLoggingDriverTest.kt +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidLoggingDriverTest.kt @@ -32,8 +32,7 @@ class AndroidLoggingDriverTest { TestTree.Log(level = LogLevel.ERROR, tag = "sargon", message = "error"), TestTree.Log(level = LogLevel.DEBUG, tag = "sargon", message = "debug") ) - // Setting to false to avoid planting android debug tree, since logTree is planted. - val sut = AndroidLoggingDriver(isLoggingEnabled = false) + val sut = AndroidLoggingDriver() input.forEach { log -> sut.log(log.level, log.message) diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidProfileStateChangeDriverTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidProfileStateChangeDriverTest.kt index 18d135cdd..77c92483a 100644 --- a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidProfileStateChangeDriverTest.kt +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidProfileStateChangeDriverTest.kt @@ -11,7 +11,7 @@ import org.junit.jupiter.api.Test class AndroidProfileStateChangeDriverTest { - private val sut = AndroidProfileStateChangeDriver() + private val sut = AndroidProfileStateChangeDriver @Test fun testProfileIsEmitted() = runTest { diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/Fakes.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/Fakes.kt new file mode 100644 index 000000000..714e8d726 --- /dev/null +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/Fakes.kt @@ -0,0 +1,83 @@ +package com.radixdlt.sargon.os.driver + +import com.radixdlt.sargon.BagOfBytes +import com.radixdlt.sargon.FileSystemDriver +import com.radixdlt.sargon.HostInfoDriver +import com.radixdlt.sargon.HostOs +import com.radixdlt.sargon.LogLevel +import com.radixdlt.sargon.LoggingDriver +import com.radixdlt.sargon.SecureStorageDriver +import com.radixdlt.sargon.SecureStorageKey +import com.radixdlt.sargon.UnsafeStorageDriver +import com.radixdlt.sargon.UnsafeStorageKey +import com.radixdlt.sargon.extensions.identifier +import com.radixdlt.sargon.extensions.other + +class FakeSecureStorageDriver: SecureStorageDriver { + private val storage: MutableMap = mutableMapOf() + + override suspend fun loadData(key: SecureStorageKey): BagOfBytes? { + return storage[key.identifier] + } + + override suspend fun saveData(key: SecureStorageKey, data: BagOfBytes) { + storage[key.identifier] = data + } + + override suspend fun deleteDataForKey(key: SecureStorageKey) { + storage.remove(key.identifier) + } +} + +class FakeUnsafeStorageDriver: UnsafeStorageDriver { + private val storage: MutableMap = mutableMapOf() + + override suspend fun loadData(key: UnsafeStorageKey): BagOfBytes? { + return storage[key.identifier] + } + + override suspend fun saveData(key: UnsafeStorageKey, data: BagOfBytes) { + storage[key.identifier] = data + } + + override suspend fun deleteDataForKey(key: UnsafeStorageKey) { + storage.remove(key.identifier) + } +} + +class FakeHostInfoDriver: HostInfoDriver { + override suspend fun hostOs(): HostOs = HostOs.other( + name = "host os", + vendor = "", + version = "1.0.0" + ) + + override suspend fun hostDeviceName(): String = "unit" + + override suspend fun hostAppVersion(): String = "1.0.0" + + override suspend fun hostDeviceModel(): String = "test" + +} + +class FakeLoggingDriver: LoggingDriver { + override fun log(level: LogLevel, msg: String) { + println("${level.name} - $msg") + } +} + +class FakeFileSystemDriver: FileSystemDriver { + private val storage: MutableMap = mutableMapOf() + + override suspend fun loadFromFile(path: String): BagOfBytes? { + return storage[path] + } + + override suspend fun saveToFile(path: String, data: BagOfBytes) { + storage[path] = data + } + + override suspend fun deleteFile(path: String) { + storage.remove(path) + } +} \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/EncryptionHelperWithEncryptionKeyTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/EncryptionHelperWithEncryptionKeyTest.kt index 9a717eba6..4e554c8e4 100644 --- a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/EncryptionHelperWithEncryptionKeyTest.kt +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/EncryptionHelperWithEncryptionKeyTest.kt @@ -2,15 +2,41 @@ package com.radixdlt.sargon.os.storage import com.radixdlt.sargon.extensions.randomBagOfBytes import com.radixdlt.sargon.extensions.then +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.slot import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import kotlin.io.encoding.ExperimentalEncodingApi +import android.util.Base64 as AndroidBase64 +import kotlin.io.encoding.Base64.Default.Mime as KotlinLikeAndroidBase64 @OptIn(ExperimentalUnsignedTypes::class, ExperimentalStdlibApi::class) class EncryptionHelperWithEncryptionKeyTest { + @OptIn(ExperimentalEncodingApi::class) + @BeforeEach + fun before() { + val byteArrayInputSlot = slot() + mockkStatic(AndroidBase64::class) + every { + AndroidBase64.encodeToString(capture(byteArrayInputSlot), AndroidBase64.DEFAULT) + } answers { + KotlinLikeAndroidBase64.encode(byteArrayInputSlot.captured) + } + + val stringInputSlot = slot() + every { + AndroidBase64.decode(capture(stringInputSlot), AndroidBase64.DEFAULT) + } answers { + KotlinLikeAndroidBase64.decode(stringInputSlot.captured) + } + } + @Test fun `decrypt with AES GCM NoPadding`() { val encryptedMessageInHex = diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/KeystoreAccessRequestTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/KeystoreAccessRequestTest.kt index 540084be5..596ab18df 100644 --- a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/KeystoreAccessRequestTest.kt +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/KeystoreAccessRequestTest.kt @@ -35,6 +35,19 @@ class KeystoreAccessRequestTest { } } + @Test + fun testSpecsOfCache() = runTest { + val request = KeystoreAccessRequest.ForCache("an alias") + + assertInstanceOf(KeySpec.Cache::class.java, request.keySpec) + + try { + request.requestAuthorization().getOrThrow() + } catch (exception: Exception) { + assert(false) { "requestAuthorization for Radix Connect should succeed but didn't" } + } + } + @Test fun testSpecsOfMnemonic() = runTest { val request = KeystoreAccessRequest.ForMnemonic( diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/StorageUtilsTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/StorageUtilsTest.kt index 58560da8f..57e6a4faf 100644 --- a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/StorageUtilsTest.kt +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/StorageUtilsTest.kt @@ -8,6 +8,8 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.slot import kotlinx.coroutines.Job import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -15,10 +17,14 @@ import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.io.File import javax.crypto.spec.SecretKeySpec +import kotlin.io.encoding.ExperimentalEncodingApi +import android.util.Base64 as AndroidBase64 +import kotlin.io.encoding.Base64.Default.Mime as KotlinLikeAndroidBase64 class StorageUtilsTest { @@ -32,6 +38,24 @@ class StorageUtilsTest { File(tmpDir, "test.preferences_pb") } + @OptIn(ExperimentalEncodingApi::class) + @BeforeEach + fun before() { + val byteArrayInputSlot = slot() + mockkStatic(AndroidBase64::class) + every { + AndroidBase64.encodeToString(capture(byteArrayInputSlot), AndroidBase64.DEFAULT) + } answers { + KotlinLikeAndroidBase64.encode(byteArrayInputSlot.captured) + } + + val stringInputSlot = slot() + every { + AndroidBase64.decode(capture(stringInputSlot), AndroidBase64.DEFAULT) + } answers { + KotlinLikeAndroidBase64.decode(stringInputSlot.captured) + } + } @Test fun testReadWhenNullValueWithoutAuhotize() = runTest(context = testDispatcher) { diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/ByteArrayKeyMappingTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/ByteArrayKeyMappingTest.kt index c4cdf03e3..6b6b90587 100644 --- a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/ByteArrayKeyMappingTest.kt +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/ByteArrayKeyMappingTest.kt @@ -1,10 +1,12 @@ package com.radixdlt.sargon.os.storage.key import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.radixdlt.sargon.ProfileId import com.radixdlt.sargon.SecureStorageKey import com.radixdlt.sargon.UnsafeStorageKey import com.radixdlt.sargon.extensions.randomBagOfBytes import com.radixdlt.sargon.extensions.toByteArray +import com.radixdlt.sargon.newProfileIdSample import com.radixdlt.sargon.os.storage.EncryptionHelper import com.radixdlt.sargon.os.storage.KeySpec import com.radixdlt.sargon.os.storage.KeystoreAccessRequest @@ -63,7 +65,7 @@ class ByteArrayKeyMappingTest { fun testSecureStorageKeyRoundtrip() = runTest(context = testDispatcher) { // Even thought profile snapshot does not store data in byte array, // it is just used to facilitate the test - val key = SecureStorageKey.ProfileSnapshot + val key = SecureStorageKey.ProfileSnapshot(newProfileIdSample()) mockProfileAccessRequest() val sut = ByteArrayKeyMapping( diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/ProfileSnapshotKeyMappingTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/ProfileSnapshotKeyMappingTest.kt index db58420aa..b9a1d3e6c 100644 --- a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/ProfileSnapshotKeyMappingTest.kt +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/ProfileSnapshotKeyMappingTest.kt @@ -54,7 +54,6 @@ class ProfileSnapshotKeyMappingTest { mockProfileAccessRequest() val sut = ProfileSnapshotKeyMapping( - key = SecureStorageKey.ProfileSnapshot, encryptedStorage = storage ) @@ -108,7 +107,6 @@ class ProfileSnapshotKeyMappingTest { } val sut = ProfileSnapshotKeyMapping( - key = SecureStorageKey.ProfileSnapshot, encryptedStorage = storage ) @@ -123,7 +121,6 @@ class ProfileSnapshotKeyMappingTest { every { storage.data } returns flow { throw IOException() } val sut = ProfileSnapshotKeyMapping( - key = SecureStorageKey.ProfileSnapshot, encryptedStorage = storage ) @@ -137,7 +134,6 @@ class ProfileSnapshotKeyMappingTest { every { storage.data } returns flow { throw RuntimeException("some error") } val sut = ProfileSnapshotKeyMapping( - key = SecureStorageKey.ProfileSnapshot, encryptedStorage = storage )