Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions core/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 0 additions & 3 deletions core/apps/api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ strum = { workspace = true }
redis = { workspace = true }
chrono = { workspace = true }
futures = { workspace = true }
gem_hash = { path = "../../crates/gem_hash" }
hex = { workspace = true }
reqwest = { workspace = true }

gem_tracing = { path = "../../crates/tracing" }
Expand All @@ -45,7 +43,6 @@ gem_rewards = { path = "../../crates/gem_rewards" }

security_provider = { path = "../../crates/security_provider" }
gem_auth = { path = "../../crates/gem_auth", features = ["client"] }
unic-langid = "0.9.6"

[dev-dependencies]
primitives = { path = "../../crates/primitives", features = ["testkit"] }
52 changes: 5 additions & 47 deletions core/apps/api/src/auth/guard.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::devices::body::read_verified_body;
use crate::responders::cache_error;
use gem_auth::{AuthClient, verify_auth_signature};
use gem_hash::sha2::sha256;
use primitives::{AuthMessage, AuthenticatedRequest, WalletId};
use primitives::{AuthMessage, AuthenticatedRequest};
use rocket::data::{FromData, Outcome, ToByteUnit};
use rocket::http::Status;
use rocket::outcome::Outcome::{Error, Success};
Expand All @@ -24,21 +24,10 @@ async fn verify_wallet_signature<'r, T: DeserializeOwned + Send, O>(req: &'r Req
return Err(error_outcome(req, Status::InternalServerError, "Auth client not available"));
};

let Ok(bytes) = data.open(32.mebibytes()).into_bytes().await else {
return Err(error_outcome(req, Status::BadRequest, "Failed to read body"));
let raw_body = match read_verified_body(req, data, 32.mebibytes()).await {
Ok(body) => body,
Err((status, message)) => return Err(error_outcome(req, status, &message)),
};
if !bytes.is_complete() {
return Err(error_outcome(req, Status::BadRequest, "Request body too large"));
}

let raw_body = bytes.into_inner();

if let Some(expected_hash) = req.headers().get_one("x-device-body-hash") {
let actual_hash = hex::encode(sha256(&raw_body));
if actual_hash != expected_hash {
return Err(error_outcome(req, Status::BadRequest, "Body hash mismatch"));
}
}

let Ok(body) = serde_json::from_slice::<AuthenticatedRequest<T>>(&raw_body) else {
return Err(error_outcome(req, Status::BadRequest, "Invalid JSON"));
Expand Down Expand Up @@ -76,12 +65,6 @@ pub struct WalletSigned<T> {
pub data: T,
}

impl<T> WalletSigned<T> {
pub fn matches_multicoin_wallet(&self, wallet_id: &WalletId) -> bool {
WalletId::Multicoin(self.address.clone()) == *wallet_id
}
}

#[rocket::async_trait]
impl<'r, T: DeserializeOwned + Send> FromData<'r> for WalletSigned<T> {
type Error = String;
Expand All @@ -98,28 +81,3 @@ impl<'r, T: DeserializeOwned + Send> FromData<'r> for WalletSigned<T> {
})
}
}

#[cfg(test)]
mod tests {
use super::WalletSigned;
use primitives::{Chain, WalletId};

const ADDRESS: &str = "0x1111111111111111111111111111111111111111";
const OTHER_ADDRESS: &str = "0x2222222222222222222222222222222222222222";

fn signed_wallet(address: &str) -> WalletSigned<()> {
WalletSigned {
address: address.to_string(),
data: (),
}
}

#[test]
fn test_matches_multicoin_wallet() {
let request = signed_wallet(ADDRESS);

assert!(request.matches_multicoin_wallet(&WalletId::Multicoin(ADDRESS.to_string())));
assert!(!request.matches_multicoin_wallet(&WalletId::Multicoin(OTHER_ADDRESS.to_string())));
assert!(!request.matches_multicoin_wallet(&WalletId::Single(Chain::Ethereum, ADDRESS.to_string())));
}
}
5 changes: 2 additions & 3 deletions core/apps/api/src/devices/auth_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@ pub struct JwtConfig {
}

pub struct AuthConfig {
pub enabled: bool,
pub tolerance: Duration,
pub jwt: JwtConfig,
}

impl AuthConfig {
pub fn new(enabled: bool, tolerance: Duration, jwt: JwtConfig) -> Self {
Self { enabled, tolerance, jwt }
pub fn new(tolerance: Duration, jwt: JwtConfig) -> Self {
Self { tolerance, jwt }
}
}
90 changes: 90 additions & 0 deletions core/apps/api/src/devices/body/json.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
use rocket::data::{FromData, Outcome, ToByteUnit};
use rocket::http::Status;
use rocket::outcome::Outcome::Success;
use rocket::{Data, Request};
use serde::de::DeserializeOwned;

use super::{error_outcome, read_verified_body};

const MAX_DEVICE_JSON_BODY_BYTES: u64 = 1024 * 1024;

pub struct DeviceJson<T>(T);

impl<T> DeviceJson<T> {
pub fn into_inner(self) -> T {
self.0
}
}

#[rocket::async_trait]
impl<'r, T: DeserializeOwned + Send> FromData<'r> for DeviceJson<T> {
type Error = String;

async fn from_data(req: &'r Request<'_>, data: Data<'r>) -> Outcome<'r, Self> {
let raw_body = match read_verified_body(req, data, MAX_DEVICE_JSON_BODY_BYTES.bytes()).await {
Ok(body) => body,
Err((status, message)) => return error_outcome(req, status, message),
};

match serde_json::from_slice::<T>(&raw_body) {
Ok(value) => Success(DeviceJson(value)),
Err(_) => error_outcome(req, Status::BadRequest, "Invalid JSON"),
}
}
}

#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};

use gem_auth::build_device_auth_header;
use rocket::http::{ContentType, Header, Status};
use rocket::local::blocking::Client;
use rocket::{Build, Rocket, post, routes};

use super::DeviceJson;
use crate::devices::auth_config::AuthConfig;
use crate::devices::constants::AUTHORIZATION_HEADER;

#[post("/", format = "json", data = "<body>")]
async fn echo(body: DeviceJson<serde_json::Value>) -> &'static str {
let _ = body;
"ok"
}

fn rocket() -> Rocket<Build> {
rocket::build().manage(AuthConfig::mock()).mount("/", routes![echo])
}

fn authorization(body: &[u8]) -> Header<'static> {
let timestamp_ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64;
Header::new(AUTHORIZATION_HEADER, build_device_auth_header(&[1u8; 32], "POST", "/", "", body, timestamp_ms).unwrap())
}

#[test]
fn test_from_data_verifies_body_hash() {
let client = Client::tracked(rocket()).unwrap();
let body = br#"{"value":"ok"}"#;

assert_eq!(
client
.post("/")
.header(ContentType::JSON)
.header(authorization(body))
.body(body.as_slice())
.dispatch()
.status(),
Status::Ok
);
assert_eq!(
client
.post("/")
.header(ContentType::JSON)
.header(authorization(body))
.body(br#"{"value":"tampered"}"#.as_slice())
.dispatch()
.status(),
Status::BadRequest
);
}
}
30 changes: 30 additions & 0 deletions core/apps/api/src/devices/body/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
mod json;
mod raw;

use rocket::data::{ByteUnit, Outcome};
use rocket::http::Status;
use rocket::outcome::Outcome::Error;
use rocket::{Data, Request};

use crate::devices::signature::verify_request_body_hash;
use crate::responders::cache_error;

pub use json::DeviceJson;
pub use raw::DeviceBody;

fn error_outcome<'r, T>(req: &'r Request<'_>, status: Status, message: impl Into<String>) -> Outcome<'r, T, String> {
let message = message.into();
cache_error(req, &message);
Error((status, message))
}

pub(crate) async fn read_verified_body<'r>(req: &'r Request<'_>, data: Data<'r>, limit: ByteUnit) -> Result<Vec<u8>, (Status, String)> {
let bytes = data.open(limit).into_bytes().await.map_err(|_| (Status::BadRequest, "Failed to read body".to_string()))?;
if !bytes.is_complete() {
return Err((Status::BadRequest, "Request body too large".to_string()));
}

let raw_body = bytes.into_inner();
verify_request_body_hash(req, &raw_body).await?;
Ok(raw_body)
}
25 changes: 25 additions & 0 deletions core/apps/api/src/devices/body/raw.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
use rocket::data::{FromData, Outcome, ToByteUnit};
use rocket::outcome::Outcome::Success;
use rocket::{Data, Request};

use super::{error_outcome, read_verified_body};

pub struct DeviceBody<const MAX_BYTES: u64>(Vec<u8>);

impl<const MAX_BYTES: u64> DeviceBody<MAX_BYTES> {
pub fn into_inner(self) -> Vec<u8> {
self.0
}
}

#[rocket::async_trait]
impl<'r, const MAX_BYTES: u64> FromData<'r> for DeviceBody<MAX_BYTES> {
type Error = String;

async fn from_data(req: &'r Request<'_>, data: Data<'r>) -> Outcome<'r, Self> {
match read_verified_body(req, data, MAX_BYTES.bytes()).await {
Ok(body) => Success(DeviceBody(body)),
Err((status, message)) => error_outcome(req, status, message),
}
}
}
2 changes: 1 addition & 1 deletion core/apps/api/src/devices/clients/address_names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ mod tests {
let asset_request = ChainAddress::new(Chain::Ethereum, "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string());
let scan_request = ChainAddress::new(Chain::Ethereum, "0x123".to_string());
let asset_name = AddressName::mock("0xdAC17F958D2ee523a2206206994597C13D831ec7", "USDT", AddressType::Contract, VerificationStatus::Verified);
let scan_name = AddressName::mock("0x123", "Legacy Name", AddressType::Address, VerificationStatus::Unverified);
let scan_name = AddressName::mock("0x123", "Scan Name", AddressType::Address, VerificationStatus::Unverified);

let scan_names = HashMap::from([(asset_request.clone(), scan_name.clone()), (scan_request.clone(), scan_name.clone())]);
let asset_names = HashMap::from([(asset_request.clone(), asset_name.clone())]);
Expand Down
1 change: 0 additions & 1 deletion core/apps/api/src/devices/clients/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,4 @@ pub use rewards_redemption::RewardsRedemptionClient;
pub use scan::{ScanClient, ScanProviderFactory};
pub use transactions::TransactionsClient;
pub use wallet_configuration::WalletConfigurationClient;
pub(crate) use wallets::WalletSubscriptionInput;
pub use wallets::WalletsClient;
19 changes: 1 addition & 18 deletions core/apps/api/src/devices/clients/wallets.rs
Original file line number Diff line number Diff line change
@@ -1,29 +1,12 @@
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::error::Error;

use primitives::{AddressChains, Chain, WalletId, WalletSource, WalletSubscription, WalletSubscriptionChains, WalletSubscriptionLegacy};
use serde::Deserialize;
use primitives::{AddressChains, Chain, WalletId, WalletSource, WalletSubscription, WalletSubscriptionChains};
use storage::models::NewWalletRow;
use storage::sql_types::WalletType;
use storage::{Database, DevicesRepository, WalletsRepository};
use streamer::{ChainAddressPayload, StreamProducer, StreamProducerQueue};

#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum WalletSubscriptionInput {
New(WalletSubscription),
Legacy(WalletSubscriptionLegacy),
}

impl WalletSubscriptionInput {
pub fn into_wallet_subscription(self) -> WalletSubscription {
match self {
Self::New(ws) => ws,
Self::Legacy(legacy) => WalletSubscription::from(legacy),
}
}
}

#[derive(Clone)]
pub struct WalletsClient {
database: Database,
Expand Down
6 changes: 0 additions & 6 deletions core/apps/api/src/devices/constants.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
pub const HEADER_DEVICE_ID: &str = "x-device-id";
pub const HEADER_WALLET_ID: &str = "x-wallet-id";
pub const HEADER_DEVICE_SIGNATURE: &str = "x-device-signature";
pub const HEADER_DEVICE_TIMESTAMP: &str = "x-device-timestamp";
pub const HEADER_DEVICE_BODY_HASH: &str = "x-device-body-hash";

pub const DEVICE_ID_LENGTH: usize = 64;

pub const AUTHORIZATION_HEADER: &str = "Authorization";
2 changes: 2 additions & 0 deletions core/apps/api/src/devices/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub enum DeviceError {
InvalidSignature,
DeviceNotFound,
WalletNotFound,
MissingWalletId,
DatabaseUnavailable,
InvalidAuthorizationFormat,
DatabaseError,
Expand All @@ -23,6 +24,7 @@ impl fmt::Display for DeviceError {
Self::InvalidSignature => write!(f, "Invalid signature"),
Self::DeviceNotFound => write!(f, "Device not found"),
Self::WalletNotFound => write!(f, "Wallet not found"),
Self::MissingWalletId => write!(f, "Missing wallet ID"),
Self::DatabaseUnavailable => write!(f, "Database not available"),
Self::InvalidAuthorizationFormat => write!(f, "Invalid authorization format"),
Self::DatabaseError => write!(f, "Database error"),
Expand Down
Loading
Loading