From e9f133d32b8cb4297b70c912ff77ec7ddfe785fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Wed, 8 Nov 2023 12:37:54 +0000 Subject: [PATCH] Implement Backup client --- Cargo.toml | 1 + README.md | 4 + chameleon/src/lib.rs | 4 +- graphql/Cargo.toml | 2 +- graphql/schemas/operations.graphql | 14 + graphql/schemas/schema_wallet_read.graphql | 134 ++++++--- graphql/src/lib.rs | 45 ++- graphql/src/schema.rs | 19 +- honey-badger/Cargo.toml | 1 + honey-badger/src/asynchronous/mod.rs | 94 ++++++ honey-badger/src/asynchronous/provider.rs | 319 +++++++++++++++++++++ honey-badger/src/lib.rs | 5 +- honey-badger/src/provider.rs | 15 +- squirrel/Cargo.toml | 17 ++ squirrel/src/lib.rs | 83 ++++++ squirrel/tests/integration_tests.rs | 132 +++++++++ 16 files changed, 843 insertions(+), 46 deletions(-) create mode 100644 honey-badger/src/asynchronous/mod.rs create mode 100644 honey-badger/src/asynchronous/provider.rs create mode 100644 squirrel/Cargo.toml create mode 100644 squirrel/src/lib.rs create mode 100644 squirrel/tests/integration_tests.rs diff --git a/Cargo.toml b/Cargo.toml index bf2adf8..955063e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,4 +5,5 @@ members = [ "graphql", "honey-badger", "mole", + "squirrel", ] diff --git a/README.md b/README.md index 34c0f17..b0d9e76 100644 --- a/README.md +++ b/README.md @@ -22,3 +22,7 @@ and always returns the latest state of them when requested. ## Crow Crows are known for their love to collect stuff. The library allows to register for and list withdraw collect offers (e.g. Lightning Address). + +## Squirrel +Squirrels love to store nuts for the winter. +The library allows backups of local data to be created. diff --git a/chameleon/src/lib.rs b/chameleon/src/lib.rs index c51df6b..db6ef0d 100644 --- a/chameleon/src/lib.rs +++ b/chameleon/src/lib.rs @@ -43,7 +43,7 @@ impl ExchangeRateProvider { .ok_or_invalid_input("Unknown currency")? .sats_per_unit; - Ok(rate) + Ok(rate as u32) } pub fn query_all_exchange_rates(&self) -> Result> { @@ -57,7 +57,7 @@ impl ExchangeRateProvider { .map(|c| { Ok(ExchangeRate { currency_code: c.currency_code, - sats_per_unit: c.sats_per_unit, + sats_per_unit: c.sats_per_unit as u32, updated_at: parse_from_rfc3339(&c.conversion_rate_updated_at)?, }) }) diff --git a/graphql/Cargo.toml b/graphql/Cargo.toml index 14162dd..66c045d 100644 --- a/graphql/Cargo.toml +++ b/graphql/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] chrono = { version = "0.4.24", default-features = false, features = ["std"] } -graphql_client = { version = "0.13.0", features = ["reqwest-blocking"]} +graphql_client = { version = "0.13.0", features = ["reqwest-blocking", "reqwest"]} log = "0.4.17" reqwest = { version = "0.11", default-features = false, features = ["json", "blocking", "rustls-tls"]} serde = { version = "1.0", features = ["derive"]} diff --git a/graphql/schemas/operations.graphql b/graphql/schemas/operations.graphql index 9e0065c..3763e8e 100644 --- a/graphql/schemas/operations.graphql +++ b/graphql/schemas/operations.graphql @@ -172,3 +172,17 @@ query MigrationBalance($nodePubKey: String) { mutation MigrateFunds($invoice: String, $base16InvoiceSignature: String, $ldkNodePubKey: String) { migrate_funds(invoice: $invoice, base16InvoiceSignature: $base16InvoiceSignature, ldkNodePubKey: $ldkNodePubKey) } + +mutation CreateBackup($encryptedBackup: String!, $schemaName: String!, $schemaVersion: String!) { + create_backup(encryptedBackup: $encryptedBackup, schemaName: $schemaName, schemaVersion: $schemaVersion) { + updatedAt + } +} + +mutation RecoverBackup($schemaName: String!) { + recover_backup(schemaName: $schemaName) { + encryptedBackup + schemaVersion + updatedAt + } +} diff --git a/graphql/schemas/schema_wallet_read.graphql b/graphql/schemas/schema_wallet_read.graphql index b67c243..afa86dc 100644 --- a/graphql/schemas/schema_wallet_read.graphql +++ b/graphql/schemas/schema_wallet_read.graphql @@ -38,18 +38,99 @@ enum ChallengeSignatureType { SECP256K1 } +type CreateBackupResponse { + schemaName: String + schemaVersion: String + updatedAt: DateTime + walletId: String +} + scalar DateTime type MigrationBalanceResponse { balanceAmountSat: BigInteger! } +input PayFailedInput { + failedAt: DateTime! + paymentHash: String! + reason: PayFailureReason! +} + +enum PayFailureReason { + NO_ROUTE + UNKNOWN +} + +input PayInitiatedInput { + executedAt: DateTime! + paidAmountMSat: BigInteger! + paymentHash: String! + processStartedAt: DateTime! + requestedAmountMSat: BigInteger! + satsPerUserCurrency: Int! + source: PaySource! + userCurrency: String! +} + +enum PaySource { + CAMERA + CLIPBOARD + MANUAL + NFC +} + +input PaySucceededInput { + confirmedAt: DateTime! + lnFeesPaidMSat: BigInteger! + paymentHash: String! +} + +input PaymentTelemetryEventsInput { + payFailed: PayFailedInput + payInitiated: PayInitiatedInput + paySucceeded: PaySucceededInput + requestInitiated: RequestInitiatedInput + requestSucceeded: RequestSucceededInput +} + +type RecoverBackupResponse { + encryptedBackup: String + schemaVersion: String + updatedAt: DateTime + walletId: String +} + type RegisterTopupResponse { email: String nodePubKey: String! walletPubKeyId: String! } +type ReportPaymentTelemetryResponse { + payFailed: String + payInitiated: String + paySucceeded: String + requestInitiated: String + requestSucceeded: String +} + +input RequestInitiatedInput { + createdAt: DateTime! + enteredAmountMSat: BigInteger! + paymentHash: String! + requestCurrency: String! + satsPerUserCurrency: Int! + userCurrency: String! +} + +input RequestSucceededInput { + channelOpeningFeeMSat: BigInteger! + paidAmountMSat: BigInteger! + paymentHash: String! + paymentReceivedAt: DateTime! +} + type SessionPermit { accessToken: String refreshToken: String @@ -732,23 +813,6 @@ enum cursor_ordering { DESC } -scalar float8 - -""" -Boolean expression to compare columns of type "float8". All fields are combined with logical 'AND'. -""" -input float8_comparison_exp { - _eq: float8 - _gt: float8 - _gte: float8 - _in: [float8!] - _is_null: Boolean - _lt: float8 - _lte: float8 - _neq: float8 - _nin: [float8!] -} - """mutation root""" type mutation_root { """ @@ -801,6 +865,7 @@ type mutation_root { where: accepted_terms_conditions_bool_exp ): accepted_terms_conditions accept_wallet_acl_by_pk(pk_columns: AcceptWalletPkRequestInput!): WalletAcl + create_backup(encryptedBackup: String!, schemaName: String!, schemaVersion: String!): CreateBackupResponse """ delete data from the table: "channel_manager" @@ -895,12 +960,14 @@ type mutation_root { on_conflict: channel_monitor_on_conflict ): channel_monitor migrate_funds(base16InvoiceSignature: String, invoice: String, ldkNodePubKey: String): Boolean! + recover_backup(schemaName: String!): RecoverBackupResponse refresh_session(refreshToken: String!): TokenContainer refresh_session_v2(refreshToken: String!): SessionPermit register_email(email: String): WalletEmail register_node(nodePubKey: String): WalletNode register_notification_token(language: String!, notificationToken: String!): Token register_topup(email: String, orderId: String!): RegisterTopupResponse + report_payment_telemetry(events: PaymentTelemetryEventsInput, telemetryId: String!): ReportPaymentTelemetryResponse start_prepared_session(challenge: String!, challengeSignature: String!, challengeSignatureType: ChallengeSignatureType, preparedPermissionToken: String!): TokenContainer start_prepared_session_v2(challenge: String!, challengeSignature: String!, challengeSignatureType: ChallengeSignatureType, preparedPermissionToken: String!): SessionPermit start_session(authPubKey: String!, challenge: String!, challengeSignature: String!, challengeSignatureType: ChallengeSignatureType, signedAuthPubKey: String!, walletPubKey: String!): TokenContainer @@ -966,6 +1033,7 @@ type query_root { where: accepted_terms_conditions_bool_exp ): [accepted_terms_conditions!]! auth_challenge: String + backup_service_version: String """ fetch data from the table: "channel_manager" @@ -1063,6 +1131,7 @@ type query_root { ): currency migration_balance(nodePubKey: String): MigrationBalanceResponse notification_service_version: String + payment_service_version: String prepare_wallet_session(challenge: String!, ownerPubKeyId: String!, signature: String!): String """ @@ -1540,14 +1609,14 @@ columns and relationships of "topup" type topup { additionalInfo: String amountSat: bigint! - amountUserCurrency: float8! + amountUserCurrency: numeric! createdAt: timestamptz! - exchangeFeeRate: float8! - exchangeFeeUserCurrency: float8! - exchangeRate: float8! + exchangeFeeRate: numeric! + exchangeFeeUserCurrency: numeric! + exchangeRate: numeric! expiresAt: timestamptz id: uuid! - lightningFeeUserCurrency: float8! + lightningFeeUserCurrency: numeric! lnurl: String nodePubKey: String! status: topup_status_enum! @@ -1563,14 +1632,14 @@ input topup_bool_exp { _or: [topup_bool_exp!] additionalInfo: String_comparison_exp amountSat: bigint_comparison_exp - amountUserCurrency: float8_comparison_exp + amountUserCurrency: numeric_comparison_exp createdAt: timestamptz_comparison_exp - exchangeFeeRate: float8_comparison_exp - exchangeFeeUserCurrency: float8_comparison_exp - exchangeRate: float8_comparison_exp + exchangeFeeRate: numeric_comparison_exp + exchangeFeeUserCurrency: numeric_comparison_exp + exchangeRate: numeric_comparison_exp expiresAt: timestamptz_comparison_exp id: uuid_comparison_exp - lightningFeeUserCurrency: float8_comparison_exp + lightningFeeUserCurrency: numeric_comparison_exp lnurl: String_comparison_exp nodePubKey: String_comparison_exp status: topup_status_enum_comparison_exp @@ -1676,14 +1745,14 @@ input topup_stream_cursor_input { input topup_stream_cursor_value_input { additionalInfo: String amountSat: bigint - amountUserCurrency: float8 + amountUserCurrency: numeric createdAt: timestamptz - exchangeFeeRate: float8 - exchangeFeeUserCurrency: float8 - exchangeRate: float8 + exchangeFeeRate: numeric + exchangeFeeUserCurrency: numeric + exchangeRate: numeric expiresAt: timestamptz id: uuid - lightningFeeUserCurrency: float8 + lightningFeeUserCurrency: numeric lnurl: String nodePubKey: String status: topup_status_enum @@ -1787,3 +1856,4 @@ input wallet_acl_stream_cursor_value_input { ownerWalletPubKeyId: uuid role: String } + diff --git a/graphql/src/lib.rs b/graphql/src/lib.rs index 6f9074c..c263379 100644 --- a/graphql/src/lib.rs +++ b/graphql/src/lib.rs @@ -6,7 +6,7 @@ pub use crate::errors::*; pub use perro; pub use reqwest; -use graphql_client::reqwest::post_graphql_blocking; +use graphql_client::reqwest::{post_graphql, post_graphql_blocking}; use graphql_client::Response; use perro::{permanent_failure, runtime_error, MapToError, OptionToError}; use reqwest::blocking::Client; @@ -37,6 +37,25 @@ pub fn build_client(access_token: Option<&str>) -> Result { Ok(client) } +pub fn build_async_client(access_token: Option<&str>) -> Result { + let user_agent = "graphql-rust/0.12.0"; + let timeout = Duration::from_secs(20); + + let mut builder = reqwest::Client::builder() + .user_agent(user_agent) + .timeout(timeout); + if let Some(access_token) = access_token { + let value = HeaderValue::from_str(&format!("Bearer {access_token}")) + .map_to_permanent_failure("Failed to build header value from str")?; + builder = builder.default_headers(std::iter::once((AUTHORIZATION, value)).collect()); + } + + let client = builder + .build() + .map_to_permanent_failure("Failed to build a async reqwest client")?; + Ok(client) +} + pub fn post_blocking( client: &Client, backend_url: &String, @@ -61,6 +80,30 @@ pub fn post_blocking( get_response_data(response, backend_url) } +pub async fn post( + client: &reqwest::Client, + backend_url: &String, + variables: Query::Variables, +) -> Result { + let response = match post_graphql::(client, backend_url, variables).await { + Ok(r) => r, + Err(e) => { + if is_502_status(e.status()) || e.to_string().contains("502") { + // checking for the error containing 502 because reqwest is unexpectedly returning a decode error instead of status error + return Err(runtime_error( + GraphQlRuntimeErrorCode::RemoteServiceUnavailable, + "The remote server returned status 502", + )); + } + return Err(runtime_error( + GraphQlRuntimeErrorCode::NetworkError, + "Failed to execute the query", + )); + } + }; + get_response_data(response, backend_url) +} + pub fn parse_from_rfc3339(rfc3339: &str) -> Result { let datetime = chrono::DateTime::parse_from_rfc3339(rfc3339).map_to_runtime_error( GraphQlRuntimeErrorCode::CorruptData, diff --git a/graphql/src/schema.rs b/graphql/src/schema.rs index e10f9a1..6cfa435 100644 --- a/graphql/src/schema.rs +++ b/graphql/src/schema.rs @@ -2,8 +2,9 @@ use graphql_client::GraphQLQuery; #[allow(non_camel_case_types)] type bytea = String; +type DateTime = String; #[allow(non_camel_case_types)] -type numeric = u32; +type numeric = float8; #[allow(non_camel_case_types)] type timestamptz = String; #[allow(non_camel_case_types)] @@ -190,3 +191,19 @@ pub struct MigrationBalance; response_derives = "Debug" )] pub struct MigrateFunds; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "schemas/schema_wallet_read.graphql", + query_path = "schemas/operations.graphql", + response_derives = "Debug" +)] +pub struct CreateBackup; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "schemas/schema_wallet_read.graphql", + query_path = "schemas/operations.graphql", + response_derives = "Debug" +)] +pub struct RecoverBackup; diff --git a/honey-badger/Cargo.toml b/honey-badger/Cargo.toml index cc13368..baece93 100644 --- a/honey-badger/Cargo.toml +++ b/honey-badger/Cargo.toml @@ -10,6 +10,7 @@ log = "0.4.17" rand = "0.8.5" secp256k1 = { version = "0.24.3", features = ["global-context"] } serde_json = "1.0" +tokio = "1.32.0" graphql = { path = "../graphql" } diff --git a/honey-badger/src/asynchronous/mod.rs b/honey-badger/src/asynchronous/mod.rs new file mode 100644 index 0000000..97bbc8d --- /dev/null +++ b/honey-badger/src/asynchronous/mod.rs @@ -0,0 +1,94 @@ +mod provider; + +pub use graphql; + +use crate::asynchronous::provider::AuthProvider; +use crate::secrets::KeyPair; +use crate::{adjust_token, AdjustedToken, AuthLevel, CustomTermsAndConditions}; +pub use graphql::errors::{GraphQlRuntimeErrorCode, Result}; +use graphql::perro::OptionToError; +use std::time::SystemTime; +use tokio::sync::Mutex; + +pub struct Auth { + provider: Mutex, + token: Mutex, +} + +impl Auth { + pub fn new( + backend_url: String, + auth_level: AuthLevel, + wallet_keypair: KeyPair, + auth_keypair: KeyPair, + ) -> Result { + let provider = AuthProvider::new(&backend_url, auth_level, &wallet_keypair, &auth_keypair)?; + let expired_token = AdjustedToken { + raw: String::new(), + expires_at: SystemTime::UNIX_EPOCH, + }; + Ok(Auth { + provider: Mutex::new(provider), + token: Mutex::new(expired_token), + }) + } + + pub async fn query_token(&self) -> Result { + if let Some(token) = self.get_token_if_valid().await { + return Ok(token); + } + + let mut provider = self.provider.lock().await; + // Anyone else refreshed the token by chance?... + if let Some(token) = self.get_token_if_valid().await { + return Ok(token); + } + + let token = adjust_token(provider.query_token().await?)?; + *self.token.lock().await = token; + self.get_token_if_valid() + .await + .ok_or_permanent_failure("Newly refreshed token is not valid long enough") + } + + pub async fn get_wallet_pubkey_id(&self) -> Option { + self.provider.lock().await.get_wallet_pubkey_id() + } + + // Not exposed in UDL, used in tests. + pub async fn refresh_token(&self) -> Result { + let mut provider = self.provider.lock().await; + let token = adjust_token(provider.query_token().await?)?; + *self.token.lock().await = token; + self.get_token_if_valid() + .await + .ok_or_permanent_failure("Newly refreshed token is not valid long enough") + } + + pub async fn accept_terms_and_conditions(&self) -> Result<()> { + let token = self.query_token().await?; + let provider = self.provider.lock().await; + provider.accept_terms_and_conditions(token).await + } + + pub async fn accept_custom_terms_and_conditions( + &self, + custom_terms: CustomTermsAndConditions, + ) -> Result<()> { + let token = self.query_token().await?; + let provider = self.provider.lock().await; + provider + .accept_custom_terms_and_conditions(custom_terms, token) + .await + } + + async fn get_token_if_valid(&self) -> Option { + let now = SystemTime::now(); + let token = self.token.lock().await; + if now < token.expires_at { + Some(token.raw.clone()) + } else { + None + } + } +} diff --git a/honey-badger/src/asynchronous/provider.rs b/honey-badger/src/asynchronous/provider.rs new file mode 100644 index 0000000..388a941 --- /dev/null +++ b/honey-badger/src/asynchronous/provider.rs @@ -0,0 +1,319 @@ +use crate::secrets::KeyPair; +use crate::signing::sign; + +use crate::{AuthLevel, CustomTermsAndConditions}; +use graphql::perro::{invalid_input, permanent_failure, runtime_error, OptionToError}; +use graphql::reqwest::Client; +use graphql::schema::*; +use graphql::{build_async_client, post}; +use graphql::{errors::*, parse_from_rfc3339}; +use log::info; +use std::time::SystemTime; + +pub(crate) struct AuthProvider { + backend_url: String, + auth_level: AuthLevel, + wallet_keypair: KeyPair, + auth_keypair: KeyPair, + client: Client, + refresh_token: Option, + wallet_pubkey_id: Option, +} + +impl AuthProvider { + pub fn new( + backend_url: &str, + auth_level: AuthLevel, + wallet_keypair: &KeyPair, + auth_keypair: &KeyPair, + ) -> Result { + let client = build_async_client(None)?; + Ok(AuthProvider { + backend_url: backend_url.to_string(), + auth_level, + wallet_keypair: wallet_keypair.clone(), + auth_keypair: auth_keypair.clone(), + client, + refresh_token: None, + wallet_pubkey_id: None, + }) + } + + pub async fn query_token(&mut self) -> Result { + let (access_token, refresh_token) = match self.refresh_token.clone() { + Some(refresh_token) => { + match self.refresh_session(refresh_token).await { + // Tolerate authentication errors and retry auth flow. + Err(Error::RuntimeError { + code: GraphQlRuntimeErrorCode::AuthServiceError, + .. + }) => self.run_auth_flow().await, + result => result, + } + } + None => self.run_auth_flow().await, + }?; + self.refresh_token = Some(refresh_token); + Ok(access_token) + } + + pub fn get_wallet_pubkey_id(&self) -> Option { + self.wallet_pubkey_id.clone() + } + + pub async fn accept_terms_and_conditions(&self, access_token: String) -> Result<()> { + info!("Accepting T&C ..."); + if self.auth_level != AuthLevel::Pseudonymous { + return Err(invalid_input( + "Accepting T&C not supported for auth levels other than Pseudonymous", + )); + } + + let variables = accept_terms_and_conditions::Variables { + pub_key_id: self.wallet_pubkey_id.clone(), + }; + let client = build_async_client(Some(&access_token))?; + let data = post::(&client, &self.backend_url, variables).await?; + if !matches!( + data.accept_terms, + Some( + accept_terms_and_conditions::AcceptTermsAndConditionsAcceptTerms { + accepted_terms: true + } + ) + ) { + return Err(permanent_failure( + "Backend rejected accepting Terms and Conditions", + )); + } + Ok(()) + } + + pub async fn accept_custom_terms_and_conditions( + &self, + custom_terms: CustomTermsAndConditions, + access_token: String, + ) -> Result<()> { + info!("Accepting custom T&C ({:?})...", custom_terms); + if self.auth_level != AuthLevel::Pseudonymous { + return Err(invalid_input( + "Accepting T&C not supported for auth levels other than Pseudonymous", + )); + } + + let service_provider = match custom_terms { + CustomTermsAndConditions::Lipa => String::from("LIPA_WALLET"), + CustomTermsAndConditions::Pocket => String::from("POCKET_EXCHANGE"), + }; + let variables = accept_custom_terms_and_conditions::Variables { service_provider }; + let client = build_async_client(Some(&access_token))?; + let data = + post::(&client, &self.backend_url, variables).await?; + if !matches!( + data.accept_terms_conditions, + Some( + accept_custom_terms_and_conditions::AcceptCustomTermsAndConditionsAcceptTermsConditions { + .. + } + ) + ) { + return Err(permanent_failure( + "Backend rejected accepting Terms and Conditions", + )); + } + Ok(()) + } + + async fn run_auth_flow(&mut self) -> Result<(String, String)> { + let (access_token, refresh_token, wallet_pub_key_id) = self.start_basic_session().await?; + + self.wallet_pubkey_id = Some(wallet_pub_key_id.clone()); + + match self.auth_level { + AuthLevel::Pseudonymous => Ok((access_token, refresh_token)), + AuthLevel::Owner => { + self.start_priviledged_session(access_token, wallet_pub_key_id) + .await + } + AuthLevel::Employee => { + let owner_pub_key_id = self + .get_business_owner(access_token.clone(), wallet_pub_key_id) + .await?; + self.start_priviledged_session(access_token, owner_pub_key_id) + .await + } + } + } + + async fn start_basic_session(&self) -> Result<(String, String, String)> { + let challenge = self.request_challenge().await?; + + let challenge_with_prefix = add_bitcoin_message_prefix(&challenge); + let challenge_signature = sign(challenge_with_prefix, self.auth_keypair.secret_key.clone()); + + let auth_pub_key_with_prefix = add_hex_prefix(&self.auth_keypair.public_key); + let signed_auth_pub_key = sign( + auth_pub_key_with_prefix, + self.wallet_keypair.secret_key.clone(), + ); + + info!("Starting session ..."); + let variables = start_session::Variables { + auth_pub_key: add_hex_prefix(&self.auth_keypair.public_key), + challenge, + challenge_signature: add_hex_prefix(&challenge_signature), + wallet_pub_key: add_hex_prefix(&self.wallet_keypair.public_key), + signed_auth_pub_key: add_hex_prefix(&signed_auth_pub_key), + }; + + let data = post::(&self.client, &self.backend_url, variables).await?; + + let session_permit = data.start_session_v2.ok_or_permanent_failure( + "Response to start_session request doesn't have the expected structure", + )?; + let access_token = session_permit.access_token.ok_or_permanent_failure( + "Response to start_session request doesn't have the expected structure: missing access token", + )?; + let refresh_token = session_permit.refresh_token.ok_or_permanent_failure( + "Response to start_session request doesn't have the expected structure: missing refresh token", + )?; + let wallet_pub_key_id = session_permit.wallet_pub_key_id.ok_or_permanent_failure( + "Response to start_session request doesn't have the expected structure: missing wallet public key id", + )?; + #[cfg(debug_assertions)] + info!("access_token: {}", access_token); + #[cfg(debug_assertions)] + info!("refresh_token: {}", refresh_token); + info!("wallet_pub_key_id: {}", wallet_pub_key_id); + Ok((access_token, refresh_token, wallet_pub_key_id)) + } + + async fn start_priviledged_session( + &self, + access_token: String, + owner_pub_key_id: String, + ) -> Result<(String, String)> { + let challenge = self.request_challenge().await?; + + let challenge_with_prefix = add_bitcoin_message_prefix(&challenge); + let challenge_signature = sign( + challenge_with_prefix, + self.wallet_keypair.secret_key.clone(), + ); + + info!("Preparing wallet session ..."); + let variables = prepare_wallet_session::Variables { + wallet_pub_key_id: owner_pub_key_id, + challenge: challenge.clone(), + signed_challenge: add_hex_prefix(&challenge_signature), + }; + + let client = build_async_client(Some(&access_token))?; + let data = post::(&client, &self.backend_url, variables).await?; + + let prepared_permission_token = data.prepare_wallet_session.ok_or_permanent_failure( + "Response to prepare_wallet_session request doesn't have the expected structure", + )?; + + info!("Starting wallet session ..."); + let variables = unlock_wallet::Variables { + challenge, + challenge_signature: add_hex_prefix(&challenge_signature), + prepared_permission_token, + }; + let data = post::(&client, &self.backend_url, variables).await?; + + let session_permit = data.start_prepared_session.ok_or_permanent_failure( + "Response to unlock_wallet request doesn't have the expected structure", + )?; + let access_token = session_permit.access_token.ok_or_permanent_failure( + "Response to unlock_wallet request doesn't have the expected structure: missing access token", + )?; + let refresh_token = session_permit.refresh_token.ok_or_permanent_failure( + "Response to unlock_wallet request doesn't have the expected structure: missing refresh token", + )?; + + #[cfg(debug_assertions)] + info!("access_token: {}", access_token); + #[cfg(debug_assertions)] + info!("refresh_token: {}", refresh_token); + + Ok((access_token, refresh_token)) + } + + async fn get_business_owner( + &self, + access_token: String, + wallet_pub_key_id: String, + ) -> Result { + info!("Getting business owner ..."); + let variables = get_business_owner::Variables { + owner_wallet_pub_key_id: wallet_pub_key_id, + }; + let client = build_async_client(Some(&access_token))?; + let data = post::(&client, &self.backend_url, variables).await?; + + let result = data + .wallet_acl + .first() + .ok_or_invalid_input("Employee does not belong to any owner")?; + + if let Some(access_expires_at) = result.access_expires_at.as_ref() { + let access_expires_at = parse_from_rfc3339(access_expires_at)?; + if SystemTime::now() > access_expires_at { + return Err(runtime_error( + GraphQlRuntimeErrorCode::AccessExpired, + "Access expired", + )); + } + } + info!("Owner: {:?}", result.owner_wallet_pub_key_id); + Ok(result.owner_wallet_pub_key_id.clone()) + } + + async fn refresh_session(&self, refresh_token: String) -> Result<(String, String)> { + // Refresh session. + info!("Refreshing session ..."); + let variables = refresh_session::Variables { refresh_token }; + let data = post::(&self.client, &self.backend_url, variables).await?; + + let session_permit = data.refresh_session.ok_or_permanent_failure( + "Response to refresh_session request doesn't have the expected structure", + )?; + let access_token = session_permit.access_token.ok_or_permanent_failure( + "Response to unlock_wallet request doesn't have the expected structure: missing access token", + )?; + let refresh_token = session_permit.refresh_token.ok_or_permanent_failure( + "Response to unlock_wallet request doesn't have the expected structure: missing refresh token", + )?; + + #[cfg(debug_assertions)] + info!("access_token: {}", access_token); + #[cfg(debug_assertions)] + info!("refresh_token: {}", refresh_token); + + Ok((access_token, refresh_token)) + } + + async fn request_challenge(&self) -> Result { + info!("Requesting challenge ..."); + let variables = request_challenge::Variables {}; + let data = post::(&self.client, &self.backend_url, variables).await?; + + let challenge = data + .auth_challenge + .ok_or_permanent_failure( + "Response to request_challenge request doesn't have the expected structure: missing auth challenge", + )?; + + Ok(challenge) + } +} + +fn add_hex_prefix(string: &str) -> String { + ["\\x", string].concat() +} + +fn add_bitcoin_message_prefix(string: &str) -> String { + ["\\x18Bitcoin Signed Message:", string].concat() +} diff --git a/honey-badger/src/lib.rs b/honey-badger/src/lib.rs index 8ff4c43..b8e31f5 100644 --- a/honey-badger/src/lib.rs +++ b/honey-badger/src/lib.rs @@ -1,3 +1,4 @@ +pub mod asynchronous; mod jwt; mod provider; pub mod secrets; @@ -35,7 +36,7 @@ impl Auth { wallet_keypair: KeyPair, auth_keypair: KeyPair, ) -> Result { - let provider = AuthProvider::new(backend_url, auth_level, wallet_keypair, auth_keypair)?; + let provider = AuthProvider::new(&backend_url, auth_level, &wallet_keypair, &auth_keypair)?; let expired_token = AdjustedToken { raw: String::new(), expires_at: SystemTime::UNIX_EPOCH, @@ -102,7 +103,7 @@ impl Auth { } } -fn adjust_token(raw_token: String) -> Result { +pub(crate) fn adjust_token(raw_token: String) -> Result { let token = parse_token(raw_token).map_to_runtime_error( GraphQlRuntimeErrorCode::AuthServiceError, "Auth service returned invalid JWT", diff --git a/honey-badger/src/provider.rs b/honey-badger/src/provider.rs index d0c7bb2..b4fe145 100644 --- a/honey-badger/src/provider.rs +++ b/honey-badger/src/provider.rs @@ -9,7 +9,7 @@ use graphql::{errors::*, parse_from_rfc3339}; use log::info; use std::time::SystemTime; -#[derive(PartialEq, Eq)] +#[derive(PartialEq, Eq, Clone, Copy)] pub enum AuthLevel { Pseudonymous, Owner, @@ -34,17 +34,18 @@ pub(crate) struct AuthProvider { impl AuthProvider { pub fn new( - backend_url: String, + backend_url: &str, auth_level: AuthLevel, - wallet_keypair: KeyPair, - auth_keypair: KeyPair, + wallet_keypair: &KeyPair, + auth_keypair: &KeyPair, ) -> Result { let client = build_client(None)?; + Ok(AuthProvider { - backend_url, + backend_url: backend_url.to_string(), auth_level, - wallet_keypair, - auth_keypair, + wallet_keypair: wallet_keypair.clone(), + auth_keypair: auth_keypair.clone(), client, refresh_token: None, wallet_pubkey_id: None, diff --git a/squirrel/Cargo.toml b/squirrel/Cargo.toml new file mode 100644 index 0000000..6e1f5c2 --- /dev/null +++ b/squirrel/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "squirrel" +version = "0.1.0" +edition = "2021" + +[dependencies] +hex = "0.4.3" + +graphql = { path = "../graphql" } +honey-badger = { path = "../honey-badger" } + +[dev-dependencies] +bitcoin = { version = "0.29.2" } +ctor = "0.2.0" +rand = "0.8.5" +simplelog = { version ="0.12.0", features = ["test"] } +tokio = { version = "1.32.0" } diff --git a/squirrel/src/lib.rs b/squirrel/src/lib.rs new file mode 100644 index 0000000..e1cc33a --- /dev/null +++ b/squirrel/src/lib.rs @@ -0,0 +1,83 @@ +use graphql::errors::*; +use graphql::perro::{runtime_error, MapToError, OptionToError}; +use graphql::schema::*; +use graphql::{build_async_client, post}; +use honey_badger::asynchronous::Auth; +use std::sync::Arc; + +#[derive(Debug, PartialEq)] +pub struct Backup { + pub encrypted_backup: Vec, + pub schema_name: String, + pub schema_version: String, +} + +pub struct RemoteBackupClient { + backend_url: String, + auth: Arc, +} + +impl RemoteBackupClient { + pub fn new(backend_url: String, auth: Arc) -> Self { + Self { backend_url, auth } + } + + pub async fn create_backup(&self, backup: &Backup) -> Result<()> { + let token = self.auth.query_token().await?; + let client = build_async_client(Some(&token))?; + let variables = create_backup::Variables { + encrypted_backup: graphql_hex_encode(&backup.encrypted_backup), + schema_name: backup.schema_name.clone(), + schema_version: backup.schema_version.clone(), + }; + post::(&client, &self.backend_url, variables).await?; + + Ok(()) + } + + pub async fn recover_backup(&self, schema_name: &str) -> Result { + let token = self.auth.query_token().await?; + let client = build_async_client(Some(&token))?; + let variables = recover_backup::Variables { + schema_name: schema_name.to_string(), + }; + let data = post::(&client, &self.backend_url, variables).await?; + + match data.recover_backup { + None => Err(runtime_error( + GraphQlRuntimeErrorCode::ObjectNotFound, + "No backup found with the provided schema name", + )), + Some(d) => { + let encrypted_backup = + graphql_hex_decode(&d.encrypted_backup.ok_or_runtime_error( + GraphQlRuntimeErrorCode::ObjectNotFound, + "No backup found with the provided schema name", + )?) + .map_to_runtime_error( + GraphQlRuntimeErrorCode::CorruptData, + "Encrypted backup invalid hex", + )?; + let schema_version = d.schema_version.ok_or_permanent_failure( + "Backend returned encrypted backup but no schema version", + )?; + Ok(Backup { + encrypted_backup, + schema_name: schema_name.to_string(), + schema_version, + }) + } + } + } +} + +fn graphql_hex_encode(data: &Vec) -> String { + format!("\\x{}", hex::encode(data)) +} + +fn graphql_hex_decode(data: &str) -> Result> { + hex::decode(data.replacen("\\x", "", 1)).map_to_runtime_error( + GraphQlRuntimeErrorCode::CorruptData, + "Could not decode hex encoded binary", + ) +} diff --git a/squirrel/tests/integration_tests.rs b/squirrel/tests/integration_tests.rs new file mode 100644 index 0000000..c2a53e8 --- /dev/null +++ b/squirrel/tests/integration_tests.rs @@ -0,0 +1,132 @@ +use bitcoin::Network; +use graphql::perro::Error::RuntimeError; +use graphql::GraphQlRuntimeErrorCode; +use honey_badger::asynchronous::Auth; +use honey_badger::secrets::{derive_keys, generate_keypair, generate_mnemonic}; +use honey_badger::AuthLevel; +use rand::random; +use simplelog::TestLogger; +use squirrel::{Backup, RemoteBackupClient}; +use std::env; +use std::sync::{Arc, Once}; + +static INIT_LOGGER_ONCE: Once = Once::new(); + +#[cfg(test)] +#[ctor::ctor] +fn init() { + INIT_LOGGER_ONCE.call_once(|| { + TestLogger::init(simplelog::LevelFilter::Info, simplelog::Config::default()).unwrap(); + }); +} + +#[tokio::test] +async fn test_recovering_backup_when_there_is_none() { + let client = build_backup_client(); + + let result = client.recover_backup("a").await; + + assert!(result.is_err()); + assert!(matches!( + result, + Err(RuntimeError { + code: GraphQlRuntimeErrorCode::ObjectNotFound, + .. + }) + )); +} + +#[tokio::test] +async fn test_backup_persistence() { + let client = build_backup_client(); + let dummy_backup_schema_a_version_1 = Backup { + encrypted_backup: random::<[u8; 32]>().to_vec(), + schema_name: "a".to_string(), + schema_version: "1".to_string(), + }; + let dummy_backup_schema_a_version_2 = Backup { + encrypted_backup: random::<[u8; 32]>().to_vec(), + schema_name: "a".to_string(), + schema_version: "2".to_string(), + }; + let dummy_backup_schema_b_version_1 = Backup { + encrypted_backup: random::<[u8; 32]>().to_vec(), + schema_name: "b".to_string(), + schema_version: "1".to_string(), + }; + let dummy_backup_schema_b_version_2 = Backup { + encrypted_backup: random::<[u8; 32]>().to_vec(), + schema_name: "b".to_string(), + schema_version: "2".to_string(), + }; + + client + .create_backup(&dummy_backup_schema_a_version_1) + .await + .unwrap(); + client + .create_backup(&dummy_backup_schema_b_version_1) + .await + .unwrap(); + + assert_eq!( + client.recover_backup("a").await.unwrap(), + dummy_backup_schema_a_version_1 + ); + assert_eq!( + client.recover_backup("b").await.unwrap(), + dummy_backup_schema_b_version_1 + ); + + client + .create_backup(&dummy_backup_schema_a_version_2) + .await + .unwrap(); + client + .create_backup(&dummy_backup_schema_b_version_2) + .await + .unwrap(); + + assert_eq!( + client.recover_backup("a").await.unwrap(), + dummy_backup_schema_a_version_2 + ); + assert_eq!( + client.recover_backup("b").await.unwrap(), + dummy_backup_schema_b_version_2 + ); + + // non-existing schema name + let result = client.recover_backup("c").await; + + assert!(result.is_err()); + assert!(matches!( + result, + Err(RuntimeError { + code: GraphQlRuntimeErrorCode::ObjectNotFound, + .. + }) + )); +} + +fn build_backup_client() -> RemoteBackupClient { + println!("Generating keys ..."); + let mnemonic = generate_mnemonic(); + println!("mnemonic: {mnemonic:?}"); + let wallet_keys = derive_keys(Network::Testnet, mnemonic).wallet_keypair; + let auth_keys = generate_keypair(); + + let auth = Auth::new( + get_backend_url(), + AuthLevel::Pseudonymous, + wallet_keys, + auth_keys, + ) + .unwrap(); + + RemoteBackupClient::new(get_backend_url(), Arc::new(auth)) +} + +fn get_backend_url() -> String { + env::var("GRAPHQL_API_URL").expect("GRAPHQL_API_URL environment variable is not set") +}