From b5fe2f55c3032cf3f79b6f6dae7a119d6cf19159 Mon Sep 17 00:00:00 2001 From: gemcoder21 <104884878+gemcoder21@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:35:58 +0000 Subject: [PATCH 1/4] Require Gem Authorization header for device auth Remove legacy per-header device auth and enforce the single Gem Authorization header across device endpoints. parse_auth_components and signature verification now expect and parse the Authorization header only (wallet_id included in the signed message), legacy header constants and decoding fallbacks were removed, and the gem_auth crate dropped AuthScheme and decode_signature. AuthConfig no longer has an `enabled` flag (always enforced) and main now constructs AuthConfig accordingly. Add DeviceError::MissingWalletId and update guards to return it when wallet-scoped endpoints lack a wallet id. Docs updated to reflect the new required header format and removed legacy header examples. --- core/apps/api/src/auth/guard.rs | 37 ++----------------- core/apps/api/src/devices/auth_config.rs | 5 +-- core/apps/api/src/devices/constants.rs | 6 --- core/apps/api/src/devices/error.rs | 2 + core/apps/api/src/devices/guard/auth.rs | 23 ++---------- .../guard/authenticated_device_wallet.rs | 3 +- core/apps/api/src/devices/mod.rs | 4 +- core/apps/api/src/devices/signature.rs | 37 +++---------------- core/apps/api/src/main.rs | 4 +- core/crates/gem_auth/src/device_signature.rs | 15 +------- core/crates/gem_auth/src/lib.rs | 3 +- core/docs/DEVICE_AUTHENTICATION.md | 37 ++----------------- core/docs/WALLET_AUTHENTICATION.md | 8 +--- 13 files changed, 28 insertions(+), 156 deletions(-) diff --git a/core/apps/api/src/auth/guard.rs b/core/apps/api/src/auth/guard.rs index 5a1cd69b57..0d3cb8cfb6 100644 --- a/core/apps/api/src/auth/guard.rs +++ b/core/apps/api/src/auth/guard.rs @@ -1,7 +1,8 @@ +use crate::devices::signature::parse_auth_components; 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}; @@ -33,7 +34,8 @@ async fn verify_wallet_signature<'r, T: DeserializeOwned + Send, O>(req: &'r Req let raw_body = bytes.into_inner(); - if let Some(expected_hash) = req.headers().get_one("x-device-body-hash") { + if let Ok(components) = parse_auth_components(req) { + let expected_hash = components.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")); @@ -76,12 +78,6 @@ pub struct WalletSigned { pub data: T, } -impl WalletSigned { - 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 { type Error = String; @@ -98,28 +94,3 @@ impl<'r, T: DeserializeOwned + Send> FromData<'r> for WalletSigned { }) } } - -#[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()))); - } -} diff --git a/core/apps/api/src/devices/auth_config.rs b/core/apps/api/src/devices/auth_config.rs index 03082ce7d5..5c6d20f15d 100644 --- a/core/apps/api/src/devices/auth_config.rs +++ b/core/apps/api/src/devices/auth_config.rs @@ -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 } } } diff --git a/core/apps/api/src/devices/constants.rs b/core/apps/api/src/devices/constants.rs index 25672a81e4..d12f02f24a 100644 --- a/core/apps/api/src/devices/constants.rs +++ b/core/apps/api/src/devices/constants.rs @@ -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"; diff --git a/core/apps/api/src/devices/error.rs b/core/apps/api/src/devices/error.rs index 8b70511202..708607210a 100644 --- a/core/apps/api/src/devices/error.rs +++ b/core/apps/api/src/devices/error.rs @@ -8,6 +8,7 @@ pub enum DeviceError { InvalidSignature, DeviceNotFound, WalletNotFound, + MissingWalletId, DatabaseUnavailable, InvalidAuthorizationFormat, DatabaseError, @@ -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"), diff --git a/core/apps/api/src/devices/guard/auth.rs b/core/apps/api/src/devices/guard/auth.rs index 9b3fae4741..daad7b72c1 100644 --- a/core/apps/api/src/devices/guard/auth.rs +++ b/core/apps/api/src/devices/guard/auth.rs @@ -7,7 +7,7 @@ use storage::models::{DeviceRow, WalletRow}; use storage::{Database, DatabaseClient, WalletsRepository}; use crate::devices::auth_config::AuthConfig; -use crate::devices::constants::{DEVICE_ID_LENGTH, HEADER_DEVICE_ID, HEADER_WALLET_ID}; +use crate::devices::constants::DEVICE_ID_LENGTH; use crate::devices::error::DeviceError; use crate::devices::signature::{parse_auth_components, verify_request_signature}; use crate::responders::cache_error; @@ -24,6 +24,7 @@ pub(super) fn auth_error_outcome(req: &Request<'_>, error: DeviceError, devic | DeviceError::InvalidTimestamp | DeviceError::TimestampExpired | DeviceError::InvalidSignature + | DeviceError::MissingWalletId | DeviceError::InvalidAuthorizationFormat => Status::Unauthorized, DeviceError::DeviceNotFound | DeviceError::WalletNotFound => Status::NotFound, DeviceError::DatabaseUnavailable | DeviceError::DatabaseError => Status::InternalServerError, @@ -49,22 +50,6 @@ pub(super) async fn authenticate(req: &Request<'_>) -> Result(req: &Request<'_>) -> Result FromRequest<'r> for AuthenticatedDeviceWallet { }; let Some(wallet_id_str) = auth.wallet_id else { - return auth_error_outcome(req, DeviceError::MissingHeader(HEADER_WALLET_ID), Some(&auth.device_id), None); + return auth_error_outcome(req, DeviceError::MissingWalletId, Some(&auth.device_id), None); }; let (device_row, wallet_row) = match lookup_device_wallet(req, &auth.device_id, &wallet_id_str).await { diff --git a/core/apps/api/src/devices/mod.rs b/core/apps/api/src/devices/mod.rs index 13e959f280..a2e77788bc 100644 --- a/core/apps/api/src/devices/mod.rs +++ b/core/apps/api/src/devices/mod.rs @@ -29,7 +29,7 @@ use primitives::rewards::{RedemptionRequest, RedemptionResult, RewardRedemptionO use primitives::{ AddressName, AssetId, AuthNonce, ChainAddress, FiatAssets, FiatQuoteRequest, FiatQuoteType, FiatQuoteUrl, FiatQuotes, InAppNotification, NFTData, PortfolioAssets, PortfolioAssetsRequest, PriceAlerts, ReportNft, RewardEvent, Rewards, ScanTransaction, ScanTransactionPayload, Transaction, TransactionsResponse, WalletConfigurationResult, - WalletSubscriptionChains, + WalletId, WalletSubscriptionChains, }; use rocket::{State, delete, get, post, put, serde::json::Json, tokio::sync::Mutex}; use std::sync::Arc; @@ -193,7 +193,7 @@ pub async fn redeem_device_rewards_v2( request: WalletSigned, client: &State>, ) -> Result, ApiError> { - if !request.matches_multicoin_wallet(&device.wallet_identifier) { + if WalletId::Multicoin(request.address.clone()) != device.wallet_identifier { return Err(ApiError::BadRequest("Wallet signature mismatch".to_string())); } diff --git a/core/apps/api/src/devices/signature.rs b/core/apps/api/src/devices/signature.rs index 6e9e8da5f8..33c09198ea 100644 --- a/core/apps/api/src/devices/signature.rs +++ b/core/apps/api/src/devices/signature.rs @@ -1,33 +1,15 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use gem_auth::{DeviceAuthPayload, decode_signature, parse_device_auth, verify_device_signature}; +use gem_auth::{DeviceAuthPayload, parse_device_auth, verify_device_signature}; use rocket::Request; use rocket::http::Status; -use crate::devices::constants::{AUTHORIZATION_HEADER, HEADER_DEVICE_BODY_HASH, HEADER_DEVICE_ID, HEADER_DEVICE_SIGNATURE, HEADER_DEVICE_TIMESTAMP}; +use crate::devices::constants::AUTHORIZATION_HEADER; use crate::devices::error::DeviceError; pub fn parse_auth_components(req: &Request<'_>) -> Result { - if let Some(auth_value) = req.headers().get_one(AUTHORIZATION_HEADER) - && auth_value.starts_with(gem_auth::GEM_AUTH_SCHEME) - { - return parse_device_auth(auth_value).ok_or(DeviceError::InvalidAuthorizationFormat); - } - - let device_id = req.headers().get_one(HEADER_DEVICE_ID).ok_or(DeviceError::MissingHeader(HEADER_DEVICE_ID))?; - let timestamp = req.headers().get_one(HEADER_DEVICE_TIMESTAMP).ok_or(DeviceError::MissingHeader(HEADER_DEVICE_TIMESTAMP))?; - let body_hash = req.headers().get_one(HEADER_DEVICE_BODY_HASH).ok_or(DeviceError::MissingHeader(HEADER_DEVICE_BODY_HASH))?; - let signature = req.headers().get_one(HEADER_DEVICE_SIGNATURE).ok_or(DeviceError::MissingHeader(HEADER_DEVICE_SIGNATURE))?; - let signature = decode_signature(signature).ok_or(DeviceError::InvalidSignature)?; - - Ok(DeviceAuthPayload { - scheme: gem_auth::AuthScheme::Legacy, - device_id: device_id.to_string(), - timestamp: timestamp.to_string(), - wallet_id: None, - body_hash: body_hash.to_string(), - signature, - }) + let auth_value = req.headers().get_one(AUTHORIZATION_HEADER).ok_or(DeviceError::MissingHeader(AUTHORIZATION_HEADER))?; + parse_device_auth(auth_value).ok_or(DeviceError::InvalidAuthorizationFormat) } pub fn verify_request_signature(req: &Request<'_>, components: &DeviceAuthPayload, tolerance_ms: u64) -> Result<(), (Status, String)> { @@ -46,15 +28,8 @@ pub fn verify_request_signature(req: &Request<'_>, components: &DeviceAuthPayloa let method = req.method().as_str(); let path = req.uri().path().as_str(); - let message = match components.scheme { - gem_auth::AuthScheme::Gem => { - let wallet_id = components.wallet_id.as_deref().unwrap_or(""); - format!("{}.{}.{}.{}.{}", components.timestamp, method, path, wallet_id, components.body_hash) - } - gem_auth::AuthScheme::Legacy => { - format!("v1.{}.{}.{}.{}", components.timestamp, method, path, components.body_hash) - } - }; + let wallet_id = components.wallet_id.as_deref().unwrap_or(""); + let message = format!("{}.{}.{}.{}.{}", components.timestamp, method, path, wallet_id, components.body_hash); if !verify_device_signature(&components.device_id, &message, &components.signature) { return Err((Status::Unauthorized, DeviceError::InvalidSignature.to_string())); diff --git a/core/apps/api/src/main.rs b/core/apps/api/src/main.rs index a22cf8cf3e..246d7a909b 100644 --- a/core/apps/api/src/main.rs +++ b/core/apps/api/src/main.rs @@ -260,7 +260,7 @@ async fn rocket_api(settings: Settings) -> Result, Box Rocket { secret: settings.api.auth.jwt.secret.clone(), expiry: settings.api.auth.jwt.expiry, }; - let auth_config = devices::auth_config::AuthConfig::new(settings.api.auth.enabled, settings.api.auth.tolerance, jwt_config); + let auth_config = devices::auth_config::AuthConfig::new(settings.api.auth.tolerance, jwt_config); rocket::build() .manage(auth_config) diff --git a/core/crates/gem_auth/src/device_signature.rs b/core/crates/gem_auth/src/device_signature.rs index 2ddd82f7ec..e023ff99c1 100644 --- a/core/crates/gem_auth/src/device_signature.rs +++ b/core/crates/gem_auth/src/device_signature.rs @@ -3,7 +3,7 @@ use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; use gem_encoding::{decode_base64, encode_base64}; use zeroize::Zeroizing; -pub const GEM_AUTH_SCHEME: &str = "Gem "; +const GEM_AUTH_SCHEME: &str = "Gem "; const ED25519_SEED_LENGTH: usize = 32; @@ -43,14 +43,7 @@ pub fn verify_device_auth(header_value: &str, method: &str, path: &str, body: &[ verify_device_signature(&payload.device_id, &message, &payload.signature) } -#[derive(Debug, PartialEq)] -pub enum AuthScheme { - Gem, - Legacy, -} - pub struct DeviceAuthPayload { - pub scheme: AuthScheme, pub device_id: String, pub timestamp: String, pub wallet_id: Option, @@ -67,7 +60,6 @@ pub fn parse_device_auth(header_value: &str) -> Option { return None; } Some(DeviceAuthPayload { - scheme: AuthScheme::Gem, device_id: parts[0].to_string(), timestamp: parts[1].to_string(), wallet_id: if parts[2].is_empty() { None } else { Some(parts[2].to_string()) }, @@ -76,11 +68,6 @@ pub fn parse_device_auth(header_value: &str) -> Option { }) } -// TODO: remove base64 fallback once all clients use hex signatures -pub fn decode_signature(value: &str) -> Option> { - hex::decode(value).ok().or_else(|| decode_base64(value).ok()) -} - pub fn verify_device_signature(public_key_hex: &str, message: &str, signature: &[u8]) -> bool { let Ok(pk_bytes) = hex::decode(public_key_hex) else { return false; diff --git a/core/crates/gem_auth/src/lib.rs b/core/crates/gem_auth/src/lib.rs index 0e4b968b1d..328b483ea3 100644 --- a/core/crates/gem_auth/src/lib.rs +++ b/core/crates/gem_auth/src/lib.rs @@ -8,8 +8,7 @@ mod signature; #[cfg(feature = "client")] pub use client::AuthClient; pub use device_signature::{ - AuthScheme, DeviceAuthPayload, GEM_AUTH_SCHEME, build_device_auth_header, decode_signature, device_auth_message, device_body_hash, device_public_key, parse_device_auth, - verify_device_auth, verify_device_signature, + DeviceAuthPayload, build_device_auth_header, device_auth_message, device_body_hash, device_public_key, parse_device_auth, verify_device_auth, verify_device_signature, }; #[cfg(feature = "client")] pub use jwt::{JwtClaims, create_device_token, verify_device_token}; diff --git a/core/docs/DEVICE_AUTHENTICATION.md b/core/docs/DEVICE_AUTHENTICATION.md index 23db87e18c..997552eaf1 100644 --- a/core/docs/DEVICE_AUTHENTICATION.md +++ b/core/docs/DEVICE_AUTHENTICATION.md @@ -2,9 +2,7 @@ ## Overview -All `/v2/devices/*` endpoints require Ed25519 request signing. New clients should use the Gem `Authorization` header. Individual `x-device-*` headers remain supported for existing clients and should be treated as legacy compatibility. - -## Gem Authorization Header +All `/v2/devices/*` endpoints require Ed25519 request signing with a single Gem `Authorization` header. Legacy individual `x-device-*` and `x-wallet-id` headers are no longer accepted. ``` Authorization: Gem base64(....) @@ -32,51 +30,22 @@ Examples: 1706000000000.GET./v2/devices/assets.multicoin_0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ``` -## Legacy Individual Headers - -The server still accepts individual headers for compatibility: - -- `x-device-id`: 64-character hex Ed25519 public key -- `x-device-signature`: Ed25519 signature, hex or base64 -- `x-device-timestamp`: Unix timestamp in milliseconds -- `x-device-body-hash`: 64-character hex SHA256 hash of the request body -- `x-wallet-id`: wallet identifier for wallet-scoped endpoints - -**Signed message:** - -``` -v1.{timestamp}.{method}.{path}.{bodyHash} -``` - -The legacy signed message does not include `walletId`; new clients should not add new dependencies on this format. - ## Request Examples -### Gem Wallet-scoped Endpoint +### Wallet-scoped Endpoint ```http GET /v2/devices/assets?from_timestamp=1234567890 Authorization: Gem base64(abc123...def456.1706000000000.multicoin_0x742d...f0bEb.e3b0c44...b855.aabb11...) ``` -### Gem Non-wallet Endpoint +### Non-wallet Endpoint ```http GET /v2/devices Authorization: Gem base64(abc123...def456.1706000000000..e3b0c44...b855.aabb11...) ``` -### Legacy Individual Headers - -```http -GET /v2/devices/assets?from_timestamp=1234567890 -x-device-id: abc123...def456 -x-wallet-id: multicoin_0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb -x-device-signature: aabb11... -x-device-timestamp: 1706000000000 -x-device-body-hash: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -``` - ## Implementation - Request signature verification: [`apps/api/src/devices/signature.rs`](../apps/api/src/devices/signature.rs) diff --git a/core/docs/WALLET_AUTHENTICATION.md b/core/docs/WALLET_AUTHENTICATION.md index d45e8f840b..c48bc6345b 100644 --- a/core/docs/WALLET_AUTHENTICATION.md +++ b/core/docs/WALLET_AUTHENTICATION.md @@ -29,12 +29,7 @@ Wallet authentication endpoints require proof of wallet ownership via blockchain } ``` -Wallet-authenticated requests are still device-authenticated requests. Use the Gem `Authorization` header for device authentication where possible; existing clients may still use the legacy individual headers documented in [Device Authentication](DEVICE_AUTHENTICATION.md). - -For the current `WalletSigned` guard, include: -- `x-device-body-hash`: SHA256 hash of request body (hex) - -This binds the wallet-signed JSON body to the request body read by the guard. Moving this check fully into the Gem `Authorization` payload should be done with the legacy-removal PR. +Wallet-authenticated requests are still device-authenticated requests. The request body hash is included in the signed `Authorization: Gem ...` device-auth payload, which binds the wallet-signed JSON body to the request. ## Nonce Request @@ -77,7 +72,6 @@ GET /v2/devices/auth/nonce POST https://api.gemwallet.com/v2/devices/rewards/referrals/create Content-Type: application/json Authorization: Gem base64(....) -x-device-body-hash: a1b2c3d4e5f6... { "auth": { From 9400f2195bf296cf93fcd9af41e26dfc9078cfeb Mon Sep 17 00:00:00 2001 From: gemcoder21 <104884878+gemcoder21@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:20:59 +0000 Subject: [PATCH 2/4] Add DeviceJson/DeviceBody and verify body hash Introduce DeviceJson and DeviceBody to centralize reading and validation of request bodies and enforce device body-hash verification. Move body hash verification into devices::signature::verify_request_body_hash (uses device_body_hash and AuthConfig tolerance) and wire it into auth guard and new FromData implementations. Replace legacy DeviceParam/Json usages across device and support endpoints to use DeviceJson/DeviceBody and adapt handlers accordingly. Remove unused dependencies from the API manifest (gem_hash, hex, unic-langid) and add tests covering DeviceJson body hash verification. --- core/Cargo.lock | 3 - core/apps/api/Cargo.toml | 3 - core/apps/api/src/auth/guard.rs | 11 +-- core/apps/api/src/devices/body/json.rs | 90 +++++++++++++++++++ core/apps/api/src/devices/body/mod.rs | 30 +++++++ core/apps/api/src/devices/body/raw.rs | 25 ++++++ .../api/src/devices/clients/address_names.rs | 2 +- core/apps/api/src/devices/clients/mod.rs | 1 - core/apps/api/src/devices/clients/wallets.rs | 19 +--- core/apps/api/src/devices/guard/auth.rs | 26 +++--- core/apps/api/src/devices/mod.rs | 60 +++++++------ core/apps/api/src/devices/signature.rs | 45 ++++++++-- core/apps/api/src/main.rs | 2 + core/apps/api/src/params.rs | 33 +------ core/apps/api/src/support/mod.rs | 24 ++--- core/apps/api/src/testkit/auth.rs | 18 ++++ core/apps/api/src/testkit/mod.rs | 1 + core/crates/primitives/src/lib.rs | 2 +- core/crates/primitives/src/subscription.rs | 25 +----- core/docs/DEVICE_AUTHENTICATION.md | 2 +- 20 files changed, 264 insertions(+), 158 deletions(-) create mode 100644 core/apps/api/src/devices/body/json.rs create mode 100644 core/apps/api/src/devices/body/mod.rs create mode 100644 core/apps/api/src/devices/body/raw.rs create mode 100644 core/apps/api/src/testkit/auth.rs create mode 100644 core/apps/api/src/testkit/mod.rs diff --git a/core/Cargo.lock b/core/Cargo.lock index 56d9f5a164..16928fa504 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -648,10 +648,8 @@ dependencies = [ "futures", "gem_auth", "gem_client", - "gem_hash", "gem_rewards", "gem_tracing", - "hex", "in_app_notifications", "localizer", "name_resolver", @@ -676,7 +674,6 @@ dependencies = [ "support", "swapper", "tokio", - "unic-langid", ] [[package]] diff --git a/core/apps/api/Cargo.toml b/core/apps/api/Cargo.toml index 608320d7b5..ccef15fde0 100644 --- a/core/apps/api/Cargo.toml +++ b/core/apps/api/Cargo.toml @@ -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" } @@ -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"] } diff --git a/core/apps/api/src/auth/guard.rs b/core/apps/api/src/auth/guard.rs index 0d3cb8cfb6..6fb399d6cf 100644 --- a/core/apps/api/src/auth/guard.rs +++ b/core/apps/api/src/auth/guard.rs @@ -1,7 +1,6 @@ -use crate::devices::signature::parse_auth_components; +use crate::devices::signature::verify_request_body_hash; use crate::responders::cache_error; use gem_auth::{AuthClient, verify_auth_signature}; -use gem_hash::sha2::sha256; use primitives::{AuthMessage, AuthenticatedRequest}; use rocket::data::{FromData, Outcome, ToByteUnit}; use rocket::http::Status; @@ -34,12 +33,8 @@ async fn verify_wallet_signature<'r, T: DeserializeOwned + Send, O>(req: &'r Req let raw_body = bytes.into_inner(); - if let Ok(components) = parse_auth_components(req) { - let expected_hash = components.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")); - } + if let Err((status, message)) = verify_request_body_hash(req, &raw_body).await { + return Err(error_outcome(req, status, &message)); } let Ok(body) = serde_json::from_slice::>(&raw_body) else { diff --git a/core/apps/api/src/devices/body/json.rs b/core/apps/api/src/devices/body/json.rs new file mode 100644 index 0000000000..595fe2847e --- /dev/null +++ b/core/apps/api/src/devices/body/json.rs @@ -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); + +impl DeviceJson { + pub fn into_inner(self) -> T { + self.0 + } +} + +#[rocket::async_trait] +impl<'r, T: DeserializeOwned + Send> FromData<'r> for DeviceJson { + 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::(&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 = "")] + async fn echo(body: DeviceJson) -> &'static str { + let _ = body; + "ok" + } + + fn rocket() -> Rocket { + 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 + ); + } +} diff --git a/core/apps/api/src/devices/body/mod.rs b/core/apps/api/src/devices/body/mod.rs new file mode 100644 index 0000000000..9166afca4e --- /dev/null +++ b/core/apps/api/src/devices/body/mod.rs @@ -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) -> Outcome<'r, T, String> { + let message = message.into(); + cache_error(req, &message); + Error((status, message)) +} + +async fn read_verified_body<'r>(req: &'r Request<'_>, data: Data<'r>, limit: ByteUnit) -> Result, (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) +} diff --git a/core/apps/api/src/devices/body/raw.rs b/core/apps/api/src/devices/body/raw.rs new file mode 100644 index 0000000000..f5f071eec7 --- /dev/null +++ b/core/apps/api/src/devices/body/raw.rs @@ -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(Vec); + +impl DeviceBody { + pub fn into_inner(self) -> Vec { + self.0 + } +} + +#[rocket::async_trait] +impl<'r, const MAX_BYTES: u64> FromData<'r> for DeviceBody { + 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), + } + } +} diff --git a/core/apps/api/src/devices/clients/address_names.rs b/core/apps/api/src/devices/clients/address_names.rs index 946732babf..23068a1448 100644 --- a/core/apps/api/src/devices/clients/address_names.rs +++ b/core/apps/api/src/devices/clients/address_names.rs @@ -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())]); diff --git a/core/apps/api/src/devices/clients/mod.rs b/core/apps/api/src/devices/clients/mod.rs index 09979a99d7..50d63f2f97 100644 --- a/core/apps/api/src/devices/clients/mod.rs +++ b/core/apps/api/src/devices/clients/mod.rs @@ -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; diff --git a/core/apps/api/src/devices/clients/wallets.rs b/core/apps/api/src/devices/clients/wallets.rs index 22d47493ce..191bfe8634 100644 --- a/core/apps/api/src/devices/clients/wallets.rs +++ b/core/apps/api/src/devices/clients/wallets.rs @@ -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, diff --git a/core/apps/api/src/devices/guard/auth.rs b/core/apps/api/src/devices/guard/auth.rs index daad7b72c1..296d9a89ce 100644 --- a/core/apps/api/src/devices/guard/auth.rs +++ b/core/apps/api/src/devices/guard/auth.rs @@ -6,10 +6,9 @@ use storage::database::devices::DevicesStore; use storage::models::{DeviceRow, WalletRow}; use storage::{Database, DatabaseClient, WalletsRepository}; -use crate::devices::auth_config::AuthConfig; use crate::devices::constants::DEVICE_ID_LENGTH; use crate::devices::error::DeviceError; -use crate::devices::signature::{parse_auth_components, verify_request_signature}; +use crate::devices::signature::verify_request_auth; use crate::responders::cache_error; pub(super) struct AuthResult { @@ -46,24 +45,21 @@ fn format_auth_error_message(error: &DeviceError, device_id: Option<&str>, walle } pub(super) async fn authenticate(req: &Request<'_>) -> Result> { - let Success(config) = req.guard::<&rocket::State>().await else { - panic!("AuthConfig not configured"); + let auth = match verify_request_auth(req).await { + Ok(auth) => auth, + Err((status, message)) => { + cache_error(req, &message); + return Err(Error((status, message))); + } }; - let components = parse_auth_components(req).map_err(|e| auth_error_outcome(req, e, None, None))?; - - if components.device_id.len() != DEVICE_ID_LENGTH { - return Err(auth_error_outcome(req, DeviceError::InvalidDeviceId, Some(&components.device_id), None)); + if auth.device_id.len() != DEVICE_ID_LENGTH { + return Err(auth_error_outcome(req, DeviceError::InvalidDeviceId, Some(&auth.device_id), None)); } - verify_request_signature(req, &components, config.tolerance.as_millis() as u64).map_err(|(status, msg)| { - cache_error(req, &msg); - Error((status, msg)) - })?; - Ok(AuthResult { - device_id: components.device_id, - wallet_id: components.wallet_id, + device_id: auth.device_id.clone(), + wallet_id: auth.wallet_id.clone(), }) } diff --git a/core/apps/api/src/devices/mod.rs b/core/apps/api/src/devices/mod.rs index a2e77788bc..9e065f95d4 100644 --- a/core/apps/api/src/devices/mod.rs +++ b/core/apps/api/src/devices/mod.rs @@ -1,4 +1,5 @@ pub mod auth_config; +pub(crate) mod body; pub mod client; pub mod clients; pub mod constants; @@ -6,13 +7,11 @@ pub mod error; pub mod guard; pub mod signature; use crate::assets::AssetsClient; -use crate::params::{ - AssetIdParam, ChainParam, ChartPeriodParam, CurrencyParam, DeviceParam, FiatProviderIdParam, FiatQuoteTypeParam, NftAssetIdParam, TransactionIdParam, UserAgent, -}; +use crate::params::{AssetIdParam, ChainParam, ChartPeriodParam, CurrencyParam, FiatProviderIdParam, FiatQuoteTypeParam, NftAssetIdParam, TransactionIdParam, UserAgent}; use crate::responders::{ApiError, ApiResponse}; use auth_config::AuthConfig; +use body::DeviceJson; pub use client::DevicesClient; -pub(crate) use clients::WalletSubscriptionInput; pub use clients::{ AddressNamesClient, FiatQuotesClient, NotificationsClient, PortfolioClient, RewardsClient, RewardsRedemptionClient, ScanClient, ScanProviderFactory, TransactionsClient, WalletConfigurationClient, WalletsClient, @@ -29,17 +28,17 @@ use primitives::rewards::{RedemptionRequest, RedemptionResult, RewardRedemptionO use primitives::{ AddressName, AssetId, AuthNonce, ChainAddress, FiatAssets, FiatQuoteRequest, FiatQuoteType, FiatQuoteUrl, FiatQuotes, InAppNotification, NFTData, PortfolioAssets, PortfolioAssetsRequest, PriceAlerts, ReportNft, RewardEvent, Rewards, ScanTransaction, ScanTransactionPayload, Transaction, TransactionsResponse, WalletConfigurationResult, - WalletId, WalletSubscriptionChains, + WalletId, WalletSubscription, WalletSubscriptionChains, }; -use rocket::{State, delete, get, post, put, serde::json::Json, tokio::sync::Mutex}; +use rocket::{State, delete, get, post, put, tokio::sync::Mutex}; use std::sync::Arc; use streamer::{StreamProducer, StreamProducerQueue}; use crate::auth::WalletSigned; #[post("/devices", format = "json", data = "")] -pub async fn add_device_v2(device_id: VerifiedDeviceId, device: DeviceParam, client: &State>) -> Result, ApiError> { - let device = device.0; +pub async fn add_device_v2(device_id: VerifiedDeviceId, device: DeviceJson, client: &State>) -> Result, ApiError> { + let device = device.into_inner(); if device.id != device_id.0 { return Err(ApiError::BadRequest("Device id mismatch".to_string())); } @@ -105,7 +104,7 @@ async fn get_device_transaction(id: TransactionIdParam, client: &State>, + requests: DeviceJson>, client: &State>, ) -> Result>, ApiError> { Ok(client.lock().await.get_address_names(requests.into_inner())?.into()) @@ -205,13 +204,13 @@ pub async fn redeem_device_rewards_v2( .into()) } -#[put("/devices", format = "json", data = "")] -pub async fn update_device_v2(device: AuthenticatedDevice, device_param: DeviceParam, client: &State>) -> Result, ApiError> { - let device_param = device_param.0; - if device_param.id != device.device_row.device_id { +#[put("/devices", format = "json", data = "")] +pub async fn update_device_v2(device: AuthenticatedDevice, device_input: DeviceJson, client: &State>) -> Result, ApiError> { + let device_input = device_input.into_inner(); + if device_input.id != device.device_row.device_id { return Err(ApiError::BadRequest("Device id mismatch".to_string())); } - Ok(client.lock().await.update_device(device_param)?.into()) + Ok(client.lock().await.update_device(device_input)?.into()) } #[post("/devices/push-notification")] @@ -227,7 +226,8 @@ pub async fn send_push_notification_device_v2(device: AuthenticatedDevice, clien } #[post("/devices/nft/report", format = "json", data = "")] -pub async fn report_device_nft_v2(device: AuthenticatedDevice, request: Json, client: &State) -> Result, ApiError> { +pub async fn report_device_nft_v2(device: AuthenticatedDevice, request: DeviceJson, client: &State) -> Result, ApiError> { + let request = request.into_inner(); let asset_id = request .asset_id .as_deref() @@ -256,10 +256,10 @@ pub async fn get_device_name_resolve_v2( #[post("/devices/scan/transaction", data = "")] pub async fn scan_device_transaction_v2( _device: AuthenticatedDevice, - request: Json, + request: DeviceJson, client: &State>, ) -> Result, ApiError> { - Ok(client.lock().await.get_scan_transaction(request.0).await?.into()) + Ok(client.lock().await.get_scan_transaction(request.into_inner()).await?.into()) } #[get("/devices/wallet_configuration")] @@ -292,20 +292,19 @@ pub async fn get_device_subscriptions_v2(device: AuthenticatedDevice, client: &S #[post("/devices/subscriptions", format = "json", data = "")] pub async fn add_device_subscriptions_v2( device: AuthenticatedDevice, - subscriptions: Json>, + subscriptions: DeviceJson>, client: &State>, ) -> Result, ApiError> { - let wallet_subscriptions = subscriptions.0.into_iter().map(|x| x.into_wallet_subscription()).collect(); - Ok(client.lock().await.add_subscriptions(device.device_row.id, wallet_subscriptions).await?.into()) + Ok(client.lock().await.add_subscriptions(device.device_row.id, subscriptions.into_inner()).await?.into()) } #[delete("/devices/subscriptions", format = "json", data = "")] pub async fn delete_device_subscriptions_v2( device: AuthenticatedDevice, - subscriptions: Json>, + subscriptions: DeviceJson>, client: &State>, ) -> Result, ApiError> { - Ok(client.lock().await.delete_subscriptions(device.device_row.id, subscriptions.0).await?.into()) + Ok(client.lock().await.delete_subscriptions(device.device_row.id, subscriptions.into_inner()).await?.into()) } #[get("/devices/auth/nonce")] @@ -335,19 +334,24 @@ pub async fn get_device_price_alerts_v2( #[post("/devices/price_alerts", format = "json", data = "")] pub async fn add_device_price_alerts_v2( device: AuthenticatedDevice, - price_alerts: Json, + price_alerts: DeviceJson, client: &State>, ) -> Result, ApiError> { - Ok(client.lock().await.add_price_alerts(&device.device_row.device_id, price_alerts.0).await?.into()) + Ok(client.lock().await.add_price_alerts(&device.device_row.device_id, price_alerts.into_inner()).await?.into()) } #[delete("/devices/price_alerts", format = "json", data = "")] pub async fn delete_device_price_alerts_v2( device: AuthenticatedDevice, - price_alerts: Json, + price_alerts: DeviceJson, client: &State>, ) -> Result, ApiError> { - Ok(client.lock().await.delete_price_alerts(&device.device_row.device_id, price_alerts.0).await?.into()) + Ok(client + .lock() + .await + .delete_price_alerts(&device.device_row.device_id, price_alerts.into_inner()) + .await? + .into()) } #[get("/devices/fiat/transactions")] @@ -416,8 +420,8 @@ pub async fn get_fiat_quote_url_v2( pub async fn get_device_portfolio_assets_v2( _device: AuthenticatedDevice, period: ChartPeriodParam, - request: Json, + request: DeviceJson, portfolio_client: &State>, ) -> Result, ApiError> { - Ok(portfolio_client.lock().await.get_portfolio_charts(request.0.assets, period.0)?.into()) + Ok(portfolio_client.lock().await.get_portfolio_charts(request.into_inner().assets, period.0)?.into()) } diff --git a/core/apps/api/src/devices/signature.rs b/core/apps/api/src/devices/signature.rs index 33c09198ea..982b2b6329 100644 --- a/core/apps/api/src/devices/signature.rs +++ b/core/apps/api/src/devices/signature.rs @@ -1,18 +1,15 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use gem_auth::{DeviceAuthPayload, parse_device_auth, verify_device_signature}; -use rocket::Request; +use gem_auth::{DeviceAuthPayload, device_auth_message, device_body_hash, parse_device_auth, verify_device_signature}; use rocket::http::Status; +use rocket::outcome::Outcome::Success; +use rocket::{Request, State}; +use crate::devices::auth_config::AuthConfig; use crate::devices::constants::AUTHORIZATION_HEADER; use crate::devices::error::DeviceError; -pub fn parse_auth_components(req: &Request<'_>) -> Result { - let auth_value = req.headers().get_one(AUTHORIZATION_HEADER).ok_or(DeviceError::MissingHeader(AUTHORIZATION_HEADER))?; - parse_device_auth(auth_value).ok_or(DeviceError::InvalidAuthorizationFormat) -} - -pub fn verify_request_signature(req: &Request<'_>, components: &DeviceAuthPayload, tolerance_ms: u64) -> Result<(), (Status, String)> { +fn verify_request_signature(req: &Request<'_>, components: &DeviceAuthPayload, tolerance_ms: u64) -> Result<(), (Status, String)> { let timestamp_ms: u64 = components .timestamp .parse() @@ -29,7 +26,7 @@ pub fn verify_request_signature(req: &Request<'_>, components: &DeviceAuthPayloa let method = req.method().as_str(); let path = req.uri().path().as_str(); let wallet_id = components.wallet_id.as_deref().unwrap_or(""); - let message = format!("{}.{}.{}.{}.{}", components.timestamp, method, path, wallet_id, components.body_hash); + let message = device_auth_message(&components.timestamp, method, path, wallet_id, &components.body_hash); if !verify_device_signature(&components.device_id, &message, &components.signature) { return Err((Status::Unauthorized, DeviceError::InvalidSignature.to_string())); @@ -37,3 +34,33 @@ pub fn verify_request_signature(req: &Request<'_>, components: &DeviceAuthPayloa Ok(()) } + +pub(crate) async fn verify_request_auth<'r>(req: &'r Request<'_>) -> Result<&'r DeviceAuthPayload, (Status, String)> { + let Success(config) = req.guard::<&State>().await else { + return Err((Status::InternalServerError, "AuthConfig not configured".to_string())); + }; + + let tolerance_ms = config.tolerance.as_millis() as u64; + req.local_cache_async(async { + let auth_value = req + .headers() + .get_one(AUTHORIZATION_HEADER) + .ok_or_else(|| (Status::Unauthorized, DeviceError::MissingHeader(AUTHORIZATION_HEADER).to_string()))?; + let components = parse_device_auth(auth_value).ok_or_else(|| (Status::Unauthorized, DeviceError::InvalidAuthorizationFormat.to_string()))?; + verify_request_signature(req, &components, tolerance_ms)?; + Ok(components) + }) + .await + .as_ref() + .map_err(|(status, message)| (*status, message.clone())) +} + +pub(crate) async fn verify_request_body_hash(req: &Request<'_>, body: &[u8]) -> Result<(), (Status, String)> { + let auth = verify_request_auth(req).await?; + + if device_body_hash(body) != auth.body_hash { + return Err((Status::BadRequest, "Body hash mismatch".to_string())); + } + + Ok(()) +} diff --git a/core/apps/api/src/main.rs b/core/apps/api/src/main.rs index 246d7a909b..d3d40572ca 100644 --- a/core/apps/api/src/main.rs +++ b/core/apps/api/src/main.rs @@ -15,6 +15,8 @@ mod responders; mod status; mod support; mod swap; +#[cfg(test)] +mod testkit; mod webhooks; mod websocket; mod websocket_prices; diff --git a/core/apps/api/src/params.rs b/core/apps/api/src/params.rs index a853d47077..e44fba6d28 100644 --- a/core/apps/api/src/params.rs +++ b/core/apps/api/src/params.rs @@ -1,13 +1,10 @@ use primitives::currency::Currency; -use primitives::{AssetId, Chain, ChartPeriod, Device, FiatProviderName, FiatQuoteType, NFTAssetId, NFTCollectionId, SwapProvider, TransactionId}; -use rocket::data::{FromData, Outcome, ToByteUnit}; +use primitives::{AssetId, Chain, ChartPeriod, FiatProviderName, FiatQuoteType, NFTAssetId, NFTCollectionId, SwapProvider, TransactionId}; +use rocket::Request; use rocket::form::{self, FromFormField, ValueField}; use rocket::http::Status; -use rocket::outcome::Outcome::{Error, Success}; use rocket::request::FromParam; -use rocket::{Data, Request}; use std::str::FromStr; -use unic_langid::LanguageIdentifier; const MAX_ADDRESS_LENGTH: usize = 256; const MAX_ASSET_ID_LENGTH: usize = 256; @@ -200,29 +197,3 @@ impl<'r> rocket::request::FromRequest<'r> for UserAgent { } } } - -pub struct DeviceParam(pub Device); - -#[rocket::async_trait] -impl<'r> FromData<'r> for DeviceParam { - type Error = String; - - async fn from_data(_req: &'r Request<'_>, data: Data<'r>) -> Outcome<'r, Self> { - let Ok(bytes) = data.open(64.kibibytes()).into_bytes().await else { - return Error((Status::BadRequest, "Failed to read body".to_string())); - }; - if !bytes.is_complete() { - return Error((Status::BadRequest, "Request body too large".to_string())); - } - - let Ok(device) = serde_json::from_slice::(&bytes.into_inner()) else { - return Error((Status::BadRequest, "Invalid JSON".to_string())); - }; - - if device.locale.parse::().is_err() { - return Error((Status::BadRequest, format!("Invalid locale: {}", device.locale))); - } - - Success(DeviceParam(device)) - } -} diff --git a/core/apps/api/src/support/mod.rs b/core/apps/api/src/support/mod.rs index 4314bd31bd..56bef3e2c1 100644 --- a/core/apps/api/src/support/mod.rs +++ b/core/apps/api/src/support/mod.rs @@ -2,10 +2,13 @@ mod client; pub use client::SupportApiClient; use primitives::{SupportAction, SupportMessage, SupportMessageInput}; -use rocket::{Data, State, data::ToByteUnit, get, http::ContentType, post, serde::json::Json, tokio::sync::Mutex}; +use rocket::{State, get, http::ContentType, post, tokio::sync::Mutex}; use crate::{ - devices::guard::AuthenticatedDevice, + devices::{ + body::{DeviceBody, DeviceJson}, + guard::AuthenticatedDevice, + }, responders::{ApiError, ApiResponse}, }; @@ -23,7 +26,7 @@ pub async fn get_support_messages( #[post("/devices/support/messages", format = "json", data = "")] pub async fn post_support_message( device: AuthenticatedDevice, - input: Json, + input: DeviceJson, client: &State>, ) -> Result, ApiError> { Ok(client.lock().await.send_message(&device.device_row, input.into_inner()).await?.into()) @@ -34,32 +37,23 @@ pub async fn post_support_image( device: AuthenticatedDevice, file_name: Option, content_type: &ContentType, - data: Data<'_>, + data: DeviceBody<{ MAX_SUPPORT_IMAGE_BYTES }>, client: &State>, ) -> Result, ApiError> { if content_type.top() != "image" { return Err(ApiError::BadRequest("Only image uploads are supported".to_string())); } - let bytes = data - .open(MAX_SUPPORT_IMAGE_BYTES.bytes()) - .into_bytes() - .await - .map_err(|error| ApiError::BadRequest(format!("Invalid image upload: {error}")))?; - if !bytes.is_complete() { - return Err(ApiError::BadRequest(format!("Image upload exceeds {MAX_SUPPORT_IMAGE_BYTES} bytes"))); - } - let file_name = file_name.unwrap_or_else(|| format!("support.{}", content_type.sub())); Ok(client .lock() .await - .send_image(&device.device_row, bytes.into_inner(), file_name, content_type.to_string()) + .send_image(&device.device_row, data.into_inner(), file_name, content_type.to_string()) .await? .into()) } #[post("/devices/support/action", format = "json", data = "")] -pub async fn post_support_action(device: AuthenticatedDevice, action: Json, client: &State>) -> Result, ApiError> { +pub async fn post_support_action(device: AuthenticatedDevice, action: DeviceJson, client: &State>) -> Result, ApiError> { Ok(client.lock().await.run_action(&device.device_row, action.into_inner()).await?.into()) } diff --git a/core/apps/api/src/testkit/auth.rs b/core/apps/api/src/testkit/auth.rs new file mode 100644 index 0000000000..29a49065cd --- /dev/null +++ b/core/apps/api/src/testkit/auth.rs @@ -0,0 +1,18 @@ +use std::time::Duration; + +use crate::devices::auth_config::{AuthConfig, JwtConfig}; + +impl JwtConfig { + pub fn mock() -> Self { + Self { + secret: "secret".to_string(), + expiry: Duration::from_secs(60), + } + } +} + +impl AuthConfig { + pub fn mock() -> Self { + Self::new(Duration::from_secs(60), JwtConfig::mock()) + } +} diff --git a/core/apps/api/src/testkit/mod.rs b/core/apps/api/src/testkit/mod.rs new file mode 100644 index 0000000000..0e4a05d597 --- /dev/null +++ b/core/apps/api/src/testkit/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/core/crates/primitives/src/lib.rs b/core/crates/primitives/src/lib.rs index 36f55b6860..09964c2568 100644 --- a/core/crates/primitives/src/lib.rs +++ b/core/crates/primitives/src/lib.rs @@ -129,7 +129,7 @@ pub mod transaction_direction; pub use self::transaction_direction::TransactionDirection; pub mod subscription; pub mod transaction_utxo; -pub use self::subscription::{AddressChains, DeviceSubscription, WalletSubscription, WalletSubscriptionChains, WalletSubscriptionLegacy}; +pub use self::subscription::{AddressChains, DeviceSubscription, WalletSubscription, WalletSubscriptionChains}; pub use self::transaction_utxo::TransactionUtxoInput; pub mod address; pub use self::address::{Address, AddressError}; diff --git a/core/crates/primitives/src/subscription.rs b/core/crates/primitives/src/subscription.rs index 66ac5f97f2..174978d104 100644 --- a/core/crates/primitives/src/subscription.rs +++ b/core/crates/primitives/src/subscription.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use serde::{Deserialize, Serialize}; use typeshare::typeshare; @@ -48,29 +48,6 @@ impl WalletSubscription { } } -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WalletSubscriptionLegacy { - pub wallet_id: WalletId, - #[serde(default)] - pub source: Option, - pub subscriptions: Vec, -} - -impl From for WalletSubscription { - fn from(legacy: WalletSubscriptionLegacy) -> Self { - let mut by_address: BTreeMap> = BTreeMap::new(); - for subscription in &legacy.subscriptions { - by_address.entry(subscription.address.clone()).or_default().push(subscription.chain); - } - Self { - wallet_id: legacy.wallet_id, - source: legacy.source, - subscriptions: by_address.into_iter().map(|(address, chains)| AddressChains::new(address, chains)).collect(), - } - } -} - #[derive(Clone, Debug, Serialize, Deserialize)] #[typeshare(swift = "Equatable, Hashable, Sendable")] #[serde(rename_all = "camelCase")] diff --git a/core/docs/DEVICE_AUTHENTICATION.md b/core/docs/DEVICE_AUTHENTICATION.md index 997552eaf1..98a53f9ef4 100644 --- a/core/docs/DEVICE_AUTHENTICATION.md +++ b/core/docs/DEVICE_AUTHENTICATION.md @@ -2,7 +2,7 @@ ## Overview -All `/v2/devices/*` endpoints require Ed25519 request signing with a single Gem `Authorization` header. Legacy individual `x-device-*` and `x-wallet-id` headers are no longer accepted. +All `/v2/devices/*` endpoints require Ed25519 request signing with a single Gem `Authorization` header. ``` Authorization: Gem base64(....) From b1e0c63b657ca66a199ee4dae2b4458b007a8b38 Mon Sep 17 00:00:00 2001 From: gemcoder21 <104884878+gemcoder21@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:14:19 +0000 Subject: [PATCH 3/4] Use read_verified_body and update tests Replace manual request body reading and explicit hash verification with read_verified_body in the API auth guard; expose read_verified_body as pub(crate). Also refactor gem_auth device signature tests to use shared constants and device_auth_message helper, pass message as &str consistently, and simplify tampered/wallet-id cases for clarity. --- core/apps/api/src/auth/guard.rs | 16 ++----- core/apps/api/src/devices/body/mod.rs | 2 +- core/crates/gem_auth/src/device_signature.rs | 45 ++++++++++---------- 3 files changed, 27 insertions(+), 36 deletions(-) diff --git a/core/apps/api/src/auth/guard.rs b/core/apps/api/src/auth/guard.rs index 6fb399d6cf..d4b45fb9da 100644 --- a/core/apps/api/src/auth/guard.rs +++ b/core/apps/api/src/auth/guard.rs @@ -1,4 +1,4 @@ -use crate::devices::signature::verify_request_body_hash; +use crate::devices::body::read_verified_body; use crate::responders::cache_error; use gem_auth::{AuthClient, verify_auth_signature}; use primitives::{AuthMessage, AuthenticatedRequest}; @@ -24,18 +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 Err((status, message)) = verify_request_body_hash(req, &raw_body).await { - return Err(error_outcome(req, status, &message)); - } let Ok(body) = serde_json::from_slice::>(&raw_body) else { return Err(error_outcome(req, Status::BadRequest, "Invalid JSON")); diff --git a/core/apps/api/src/devices/body/mod.rs b/core/apps/api/src/devices/body/mod.rs index 9166afca4e..6afc00fe22 100644 --- a/core/apps/api/src/devices/body/mod.rs +++ b/core/apps/api/src/devices/body/mod.rs @@ -18,7 +18,7 @@ fn error_outcome<'r, T>(req: &'r Request<'_>, status: Status, message: impl Into Error((status, message)) } -async fn read_verified_body<'r>(req: &'r Request<'_>, data: Data<'r>, limit: ByteUnit) -> Result, (Status, String)> { +pub(crate) async fn read_verified_body<'r>(req: &'r Request<'_>, data: Data<'r>, limit: ByteUnit) -> Result, (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())); diff --git a/core/crates/gem_auth/src/device_signature.rs b/core/crates/gem_auth/src/device_signature.rs index e023ff99c1..ec86443cb4 100644 --- a/core/crates/gem_auth/src/device_signature.rs +++ b/core/crates/gem_auth/src/device_signature.rs @@ -92,34 +92,39 @@ mod tests { use ed25519_dalek::{Signer, SigningKey}; use gem_encoding::encode_base64; + const TIMESTAMP: &str = "1706000000000"; + const METHOD: &str = "GET"; + const PATH: &str = "/v2/devices"; + const BODY_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + #[test] fn test_verify_valid_signature() { let signing_key = SigningKey::from_bytes(&[1u8; 32]); let public_key_hex = hex::encode(signing_key.verifying_key().as_bytes()); - let message = "v1.1706000000000.GET./v1/devices/abc.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + let message = device_auth_message(TIMESTAMP, METHOD, PATH, "", BODY_HASH); let signature = signing_key.sign(message.as_bytes()); - assert!(verify_device_signature(&public_key_hex, message, &signature.to_bytes())); + assert!(verify_device_signature(&public_key_hex, &message, &signature.to_bytes())); } #[test] fn test_reject_invalid_signature() { let signing_key = SigningKey::from_bytes(&[1u8; 32]); let public_key_hex = hex::encode(signing_key.verifying_key().as_bytes()); - let message = "v1.1706000000000.GET./v1/devices/abc.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + let message = device_auth_message(TIMESTAMP, METHOD, PATH, "", BODY_HASH); - assert!(!verify_device_signature(&public_key_hex, message, &[0u8; 64])); + assert!(!verify_device_signature(&public_key_hex, &message, &[0u8; 64])); } #[test] fn test_reject_tampered_message() { let signing_key = SigningKey::from_bytes(&[1u8; 32]); let public_key_hex = hex::encode(signing_key.verifying_key().as_bytes()); - let message = "v1.1706000000000.GET./v1/devices/abc.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + let message = device_auth_message(TIMESTAMP, METHOD, PATH, "", BODY_HASH); let signature = signing_key.sign(message.as_bytes()); - let tampered = "v1.1706000000000.POST./v1/devices/abc.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; - assert!(!verify_device_signature(&public_key_hex, tampered, &signature.to_bytes())); + let tampered = device_auth_message(TIMESTAMP, "POST", PATH, "", BODY_HASH); + assert!(!verify_device_signature(&public_key_hex, &tampered, &signature.to_bytes())); } #[test] @@ -127,10 +132,10 @@ mod tests { let signing_key = SigningKey::from_bytes(&[1u8; 32]); let wrong_key = SigningKey::from_bytes(&[2u8; 32]); let wrong_public_key_hex = hex::encode(wrong_key.verifying_key().as_bytes()); - let message = "v1.1706000000000.GET./v1/devices/abc.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + let message = device_auth_message(TIMESTAMP, METHOD, PATH, "", BODY_HASH); let signature = signing_key.sign(message.as_bytes()); - assert!(!verify_device_signature(&wrong_public_key_hex, message, &signature.to_bytes())); + assert!(!verify_device_signature(&wrong_public_key_hex, &message, &signature.to_bytes())); } #[test] @@ -142,21 +147,19 @@ mod tests { fn test_parse_device_auth() { let signing_key = SigningKey::from_bytes(&[1u8; 32]); let public_key_hex = hex::encode(signing_key.verifying_key().as_bytes()); - let timestamp = "1706000000000"; - let body_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; let wallet_id = "multicoin_0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb"; let signature = signing_key.sign(b"test"); let signature_hex = hex::encode(signature.to_bytes()); - let payload = format!("{}.{}.{}.{}.{}", public_key_hex, timestamp, wallet_id, body_hash, signature_hex); + let payload = format!("{}.{}.{}.{}.{}", public_key_hex, TIMESTAMP, wallet_id, BODY_HASH, signature_hex); let encoded = encode_base64(payload.as_bytes()); let header = format!("Gem {}", encoded); let result = parse_device_auth(&header).unwrap(); assert_eq!(result.device_id, public_key_hex); - assert_eq!(result.timestamp, timestamp); + assert_eq!(result.timestamp, TIMESTAMP); assert_eq!(result.wallet_id.as_deref(), Some(wallet_id)); - assert_eq!(result.body_hash, body_hash); + assert_eq!(result.body_hash, BODY_HASH); assert_eq!(result.signature, signature.to_bytes()); } @@ -172,20 +175,18 @@ mod tests { fn test_parse_device_auth_empty_wallet_id() { let signing_key = SigningKey::from_bytes(&[1u8; 32]); let public_key_hex = hex::encode(signing_key.verifying_key().as_bytes()); - let timestamp = "1706000000000"; - let body_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; let signature = signing_key.sign(b"test"); let signature_hex = hex::encode(signature.to_bytes()); - let payload = format!("{}.{}..{}.{}", public_key_hex, timestamp, body_hash, signature_hex); + let payload = format!("{}.{}..{}.{}", public_key_hex, TIMESTAMP, BODY_HASH, signature_hex); let encoded = encode_base64(payload.as_bytes()); let header = format!("Gem {}", encoded); let result = parse_device_auth(&header).unwrap(); assert_eq!(result.device_id, public_key_hex); - assert_eq!(result.timestamp, timestamp); + assert_eq!(result.timestamp, TIMESTAMP); assert_eq!(result.wallet_id, None); - assert_eq!(result.body_hash, body_hash); + assert_eq!(result.body_hash, BODY_HASH); } #[test] @@ -193,8 +194,7 @@ mod tests { let signing_key = SigningKey::from_bytes(&[1u8; 32]); let public_key_hex = hex::encode(signing_key.verifying_key().as_bytes()); let wallet_id = "multicoin_0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb"; - let body_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; - let message = format!("1706000000000.GET./v1/devices/abc.{}.{}", wallet_id, body_hash); + let message = device_auth_message(TIMESTAMP, METHOD, PATH, wallet_id, BODY_HASH); let signature = signing_key.sign(message.as_bytes()); assert!(verify_device_signature(&public_key_hex, &message, &signature.to_bytes())); @@ -204,8 +204,7 @@ mod tests { fn test_verify_signature_empty_wallet_id() { let signing_key = SigningKey::from_bytes(&[1u8; 32]); let public_key_hex = hex::encode(signing_key.verifying_key().as_bytes()); - let body_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; - let message = format!("1706000000000.GET./v1/devices/abc..{}", body_hash); + let message = device_auth_message(TIMESTAMP, METHOD, PATH, "", BODY_HASH); let signature = signing_key.sign(message.as_bytes()); assert!(verify_device_signature(&public_key_hex, &message, &signature.to_bytes())); From 48522d91c243f330b8e79757f483b166b0b5bf5e Mon Sep 17 00:00:00 2001 From: gemcoder21 <104884878+gemcoder21@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:42:49 +0000 Subject: [PATCH 4/4] Remove unused imports in swapper provider tests Drop unused test imports in swapper crate: remove Options from chainflip provider tests and remove SOLANA_USDC_TOKEN_ID from mayan provider tests to clean up unused-import warnings. --- core/crates/swapper/src/chainflip/provider.rs | 2 +- core/crates/swapper/src/mayan/provider.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/crates/swapper/src/chainflip/provider.rs b/core/crates/swapper/src/chainflip/provider.rs index 2e555e8cce..18610f0e66 100644 --- a/core/crates/swapper/src/chainflip/provider.rs +++ b/core/crates/swapper/src/chainflip/provider.rs @@ -319,7 +319,7 @@ where #[cfg(test)] mod tests { use super::*; - use crate::{Options, SwapperQuoteAsset}; + use crate::SwapperQuoteAsset; use primitives::AssetId; #[test] diff --git a/core/crates/swapper/src/mayan/provider.rs b/core/crates/swapper/src/mayan/provider.rs index 93779a961e..dbf292f12b 100644 --- a/core/crates/swapper/src/mayan/provider.rs +++ b/core/crates/swapper/src/mayan/provider.rs @@ -244,7 +244,7 @@ mod tests { use gem_client::testkit::MockClient; use primitives::{ AssetId, - asset_constants::{ARBITRUM_USDC_ASSET_ID, HYPERCORE_SPOT_USDC_ASSET_ID, SOLANA_USDC_TOKEN_ID}, + asset_constants::{ARBITRUM_USDC_ASSET_ID, HYPERCORE_SPOT_USDC_ASSET_ID}, }; use std::collections::BTreeSet;