Skip to content
This repository was archived by the owner on Jun 1, 2026. It is now read-only.
Draft
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
37 changes: 4 additions & 33 deletions apps/api/src/auth/guard.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -76,12 +78,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 +94,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 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 }
}
}
6 changes: 0 additions & 6 deletions 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";
Expand Down
2 changes: 2 additions & 0 deletions 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
23 changes: 3 additions & 20 deletions apps/api/src/devices/guard/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,6 +24,7 @@ pub(super) fn auth_error_outcome<T>(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,
Expand All @@ -49,22 +50,6 @@ pub(super) async fn authenticate<T>(req: &Request<'_>) -> Result<AuthResult, Out
panic!("AuthConfig not configured");
};

if !config.enabled {
let device_id = req
.headers()
.get_one(HEADER_DEVICE_ID)
.ok_or_else(|| auth_error_outcome(req, DeviceError::MissingHeader(HEADER_DEVICE_ID), None, None))?;

if device_id.len() != DEVICE_ID_LENGTH {
return Err(auth_error_outcome(req, DeviceError::InvalidDeviceId, Some(device_id), None));
}

return Ok(AuthResult {
device_id: device_id.to_string(),
wallet_id: req.headers().get_one(HEADER_WALLET_ID).map(|s| s.to_string()),
});
}

let components = parse_auth_components(req).map_err(|e| auth_error_outcome(req, e, None, None))?;

if components.device_id.len() != DEVICE_ID_LENGTH {
Expand All @@ -76,11 +61,9 @@ pub(super) async fn authenticate<T>(req: &Request<'_>) -> Result<AuthResult, Out
Error((status, msg))
})?;

let wallet_id = components.wallet_id.clone().or_else(|| req.headers().get_one(HEADER_WALLET_ID).map(|s| s.to_string()));

Ok(AuthResult {
device_id: components.device_id,
wallet_id,
wallet_id: components.wallet_id,
})
}

Expand Down
3 changes: 1 addition & 2 deletions apps/api/src/devices/guard/authenticated_device_wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ use rocket::request::{FromRequest, Outcome};
use storage::models::DeviceRow;

use super::auth::{auth_error_outcome, authenticate, lookup_device_wallet};
use crate::devices::constants::HEADER_WALLET_ID;
use crate::devices::error::DeviceError;

// Verifies control of the device key, then resolves a wallet attached to that device.
Expand All @@ -27,7 +26,7 @@ impl<'r> 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 {
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/devices/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use primitives::rewards::{RedemptionRequest, RedemptionResult, RewardRedemptionO
use primitives::{
AddressName, AssetId, AuthNonce, ChainAddress, FiatAssets, FiatQuote, 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;
Expand Down Expand Up @@ -193,7 +193,7 @@ pub async fn redeem_device_rewards_v2(
request: WalletSigned<RedemptionRequest>,
client: &State<Mutex<RewardsRedemptionClient>>,
) -> Result<ApiResponse<RedemptionResult>, 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()));
}

Expand Down
37 changes: 6 additions & 31 deletions apps/api/src/devices/signature.rs
Original file line number Diff line number Diff line change
@@ -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<DeviceAuthPayload, DeviceError> {
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)> {
Expand All @@ -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()));
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ async fn rocket_api(settings: Settings) -> Result<Rocket<Build>, Box<dyn std::er
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);
let mut rocket = rocket::build()
.manage(auth_config)
.manage(database)
Expand Down Expand Up @@ -321,7 +321,7 @@ async fn rocket_ws_stream(settings: Settings) -> Rocket<Build> {
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)
Expand Down
15 changes: 1 addition & 14 deletions crates/gem_auth/src/device_signature.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,9 @@ use alloy_primitives::hex;
use ed25519_dalek::{Signature, VerifyingKey};
use gem_encoding::decode_base64;

pub const GEM_AUTH_SCHEME: &str = "Gem ";

#[derive(Debug, PartialEq)]
pub enum AuthScheme {
Gem,
Legacy,
}
const GEM_AUTH_SCHEME: &str = "Gem ";

pub struct DeviceAuthPayload {
pub scheme: AuthScheme,
pub device_id: String,
pub timestamp: String,
pub wallet_id: Option<String>,
Expand All @@ -28,7 +21,6 @@ pub fn parse_device_auth(header_value: &str) -> Option<DeviceAuthPayload> {
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()) },
Expand All @@ -37,11 +29,6 @@ pub fn parse_device_auth(header_value: &str) -> Option<DeviceAuthPayload> {
})
}

// TODO: remove base64 fallback once all clients use hex signatures
pub fn decode_signature(value: &str) -> Option<Vec<u8>> {
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;
Expand Down
2 changes: 1 addition & 1 deletion crates/gem_auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ mod signature;

#[cfg(feature = "client")]
pub use client::AuthClient;
pub use device_signature::{AuthScheme, DeviceAuthPayload, GEM_AUTH_SCHEME, decode_signature, parse_device_auth, verify_device_signature};
pub use device_signature::{DeviceAuthPayload, parse_device_auth, verify_device_signature};
#[cfg(feature = "client")]
pub use jwt::{JwtClaims, create_device_token, verify_device_token};
pub use signature::{AuthMessageData, create_auth_hash, verify_auth_signature};
37 changes: 3 additions & 34 deletions docs/DEVICE_AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(<device_id_hex>.<timestamp_ms>.<wallet_id>.<body_hash_hex>.<signature_hex>)
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 1 addition & 7 deletions docs/WALLET_AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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

Expand Down Expand Up @@ -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(<device_id_hex>.<timestamp_ms>.<wallet_id>.<body_hash_hex>.<signature_hex>)
x-device-body-hash: a1b2c3d4e5f6...

{
"auth": {
Expand Down
Loading