Skip to content
This repository was archived by the owner on Jun 1, 2026. It is now read-only.

Commit 08cd1e6

Browse files
committed
Tighten device wallet auth and wallet signature binding
1 parent 6e7853d commit 08cd1e6

9 files changed

Lines changed: 180 additions & 91 deletions

File tree

apps/api/src/auth/guard.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::responders::cache_error;
22
use gem_auth::{AuthClient, verify_auth_signature};
33
use gem_hash::sha2::sha256;
4-
use primitives::{AuthMessage, AuthenticatedRequest};
4+
use primitives::{AuthMessage, AuthenticatedRequest, WalletId};
55
use rocket::data::{FromData, Outcome, ToByteUnit};
66
use rocket::http::Status;
77
use rocket::outcome::Outcome::{Error, Success};
@@ -67,11 +67,21 @@ async fn verify_wallet_signature<'r, T: DeserializeOwned + Send, O>(req: &'r Req
6767
})
6868
}
6969

70+
// Auth layering principles:
71+
// Device guards verify the request/device signature and database scope.
72+
// WalletSigned verifies wallet ownership of the signed JSON body using an auth nonce.
73+
// Routes that mutate wallet-owned reward state should require both and bind the signed wallet to the resolved wallet.
7074
pub struct WalletSigned<T> {
7175
pub address: String,
7276
pub data: T,
7377
}
7478

79+
impl<T> WalletSigned<T> {
80+
pub fn matches_multicoin_wallet(&self, wallet_id: &WalletId) -> bool {
81+
WalletId::Multicoin(self.address.clone()) == *wallet_id
82+
}
83+
}
84+
7585
#[rocket::async_trait]
7686
impl<'r, T: DeserializeOwned + Send> FromData<'r> for WalletSigned<T> {
7787
type Error = String;
@@ -88,3 +98,28 @@ impl<'r, T: DeserializeOwned + Send> FromData<'r> for WalletSigned<T> {
8898
})
8999
}
90100
}
101+
102+
#[cfg(test)]
103+
mod tests {
104+
use super::WalletSigned;
105+
use primitives::{Chain, WalletId};
106+
107+
const ADDRESS: &str = "0x1111111111111111111111111111111111111111";
108+
const OTHER_ADDRESS: &str = "0x2222222222222222222222222222222222222222";
109+
110+
fn signed_wallet(address: &str) -> WalletSigned<()> {
111+
WalletSigned {
112+
address: address.to_string(),
113+
data: (),
114+
}
115+
}
116+
117+
#[test]
118+
fn test_matches_multicoin_wallet() {
119+
let request = signed_wallet(ADDRESS);
120+
121+
assert!(request.matches_multicoin_wallet(&WalletId::Multicoin(ADDRESS.to_string())));
122+
assert!(!request.matches_multicoin_wallet(&WalletId::Multicoin(OTHER_ADDRESS.to_string())));
123+
assert!(!request.matches_multicoin_wallet(&WalletId::Single(Chain::Ethereum, ADDRESS.to_string())));
124+
}
125+
}
Lines changed: 23 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
1-
use primitives::WalletId;
21
use rocket::Request;
32
use rocket::http::Status;
43
use rocket::outcome::Outcome::{Error, Success};
5-
use rocket::request::{FromRequest, Outcome};
6-
use storage::Database;
4+
use rocket::request::Outcome;
75
use storage::database::devices::DevicesStore;
8-
use storage::database::wallets::WalletsStore;
9-
use storage::models::DeviceRow;
6+
use storage::models::{DeviceRow, WalletRow};
7+
use storage::{Database, DatabaseClient, WalletsRepository};
108

119
use crate::devices::auth_config::AuthConfig;
1210
use crate::devices::constants::{DEVICE_ID_LENGTH, HEADER_DEVICE_ID, HEADER_WALLET_ID};
1311
use crate::devices::error::DeviceError;
1412
use crate::devices::signature::{parse_auth_components, verify_request_signature};
1513
use crate::responders::cache_error;
1614

17-
fn auth_error_outcome<T>(req: &Request<'_>, error: DeviceError, device_id: Option<&str>, wallet_id: Option<&str>) -> Outcome<T, String> {
15+
pub(super) struct AuthResult {
16+
pub(super) device_id: String,
17+
pub(super) wallet_id: Option<String>,
18+
}
19+
20+
pub(super) fn auth_error_outcome<T>(req: &Request<'_>, error: DeviceError, device_id: Option<&str>, wallet_id: Option<&str>) -> Outcome<T, String> {
1821
let status = match error {
1922
DeviceError::MissingHeader(_)
2023
| DeviceError::InvalidDeviceId
@@ -41,12 +44,7 @@ fn format_auth_error_message(error: &DeviceError, device_id: Option<&str>, walle
4144
message
4245
}
4346

44-
struct AuthResult {
45-
device_id: String,
46-
wallet_id: Option<String>,
47-
}
48-
49-
async fn authenticate<T>(req: &Request<'_>) -> Result<AuthResult, Outcome<T, String>> {
47+
pub(super) async fn authenticate<T>(req: &Request<'_>) -> Result<AuthResult, Outcome<T, String>> {
5048
let Success(config) = req.guard::<&rocket::State<AuthConfig>>().await else {
5149
panic!("AuthConfig not configured");
5250
};
@@ -86,84 +84,7 @@ async fn authenticate<T>(req: &Request<'_>) -> Result<AuthResult, Outcome<T, Str
8684
})
8785
}
8886

89-
// Signature verified, no database check (for device registration)
90-
pub struct VerifiedDeviceId(pub String);
91-
92-
#[rocket::async_trait]
93-
impl<'r> FromRequest<'r> for VerifiedDeviceId {
94-
type Error = String;
95-
96-
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, String> {
97-
match authenticate(req).await {
98-
Ok(auth) => Success(VerifiedDeviceId(auth.device_id)),
99-
Err(error) => error,
100-
}
101-
}
102-
}
103-
104-
// Signature verified + device exists in database
105-
pub struct AuthenticatedDevice {
106-
pub device_row: DeviceRow,
107-
}
108-
109-
#[rocket::async_trait]
110-
impl<'r> FromRequest<'r> for AuthenticatedDevice {
111-
type Error = String;
112-
113-
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, String> {
114-
let auth = match authenticate(req).await {
115-
Ok(auth) => auth,
116-
Err(error) => return error,
117-
};
118-
119-
let (device_row, _) = match lookup_device(req, &auth.device_id).await {
120-
Ok(result) => result,
121-
Err(error) => return error,
122-
};
123-
124-
Success(AuthenticatedDevice { device_row })
125-
}
126-
}
127-
128-
// Signature verified + device and wallet exist in database
129-
pub struct AuthenticatedDeviceWallet {
130-
pub device_row: DeviceRow,
131-
pub wallet_id: i32,
132-
pub wallet_identifier: WalletId,
133-
}
134-
135-
#[rocket::async_trait]
136-
impl<'r> FromRequest<'r> for AuthenticatedDeviceWallet {
137-
type Error = String;
138-
139-
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, String> {
140-
let auth = match authenticate(req).await {
141-
Ok(auth) => auth,
142-
Err(error) => return error,
143-
};
144-
145-
let Some(wallet_id_str) = auth.wallet_id else {
146-
return auth_error_outcome(req, DeviceError::MissingHeader(HEADER_WALLET_ID), Some(&auth.device_id), None);
147-
};
148-
149-
let (device_row, mut db_client) = match lookup_device(req, &auth.device_id).await {
150-
Ok(result) => result,
151-
Err(error) => return error,
152-
};
153-
154-
let Ok(wallet_row) = WalletsStore::get_wallet(&mut db_client, &wallet_id_str) else {
155-
return auth_error_outcome(req, DeviceError::WalletNotFound, Some(&auth.device_id), Some(&wallet_id_str));
156-
};
157-
158-
Success(AuthenticatedDeviceWallet {
159-
device_row,
160-
wallet_id: wallet_row.id,
161-
wallet_identifier: wallet_row.wallet_id.0,
162-
})
163-
}
164-
}
165-
166-
async fn lookup_device<T>(req: &Request<'_>, device_id: &str) -> Result<(DeviceRow, storage::DatabaseClient), Outcome<T, String>> {
87+
pub(super) async fn lookup_device<T>(req: &Request<'_>, device_id: &str) -> Result<(DeviceRow, DatabaseClient), Outcome<T, String>> {
16788
let Success(database) = req.guard::<&rocket::State<Database>>().await else {
16889
return Err(auth_error_outcome(req, DeviceError::DatabaseUnavailable, Some(device_id), None));
16990
};
@@ -179,6 +100,18 @@ async fn lookup_device<T>(req: &Request<'_>, device_id: &str) -> Result<(DeviceR
179100
Ok((device_row, db_client))
180101
}
181102

103+
pub(super) async fn lookup_device_wallet<T>(req: &Request<'_>, device_id: &str, wallet_id: &str) -> Result<(DeviceRow, WalletRow), Outcome<T, String>> {
104+
let (device_row, mut db_client) = lookup_device(req, device_id).await?;
105+
106+
let wallet_row = match db_client.get_wallet_by_device_and_identifier(device_row.id, wallet_id) {
107+
Ok(wallet_row) => wallet_row,
108+
Err(error) if error.is_not_found() => return Err(auth_error_outcome(req, DeviceError::WalletNotFound, Some(device_id), Some(wallet_id))),
109+
Err(_) => return Err(auth_error_outcome(req, DeviceError::DatabaseError, Some(device_id), Some(wallet_id))),
110+
};
111+
112+
Ok((device_row, wallet_row))
113+
}
114+
182115
#[cfg(test)]
183116
mod tests {
184117
use super::format_auth_error_message;
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
use rocket::Request;
2+
use rocket::outcome::Outcome::Success;
3+
use rocket::request::{FromRequest, Outcome};
4+
use storage::models::DeviceRow;
5+
6+
use super::auth::{authenticate, lookup_device};
7+
8+
// Verifies the device request signature, then checks that the device exists.
9+
pub struct AuthenticatedDevice {
10+
pub device_row: DeviceRow,
11+
}
12+
13+
#[rocket::async_trait]
14+
impl<'r> FromRequest<'r> for AuthenticatedDevice {
15+
type Error = String;
16+
17+
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, String> {
18+
let auth = match authenticate(req).await {
19+
Ok(auth) => auth,
20+
Err(error) => return error,
21+
};
22+
23+
let (device_row, _) = match lookup_device(req, &auth.device_id).await {
24+
Ok(result) => result,
25+
Err(error) => return error,
26+
};
27+
28+
Success(AuthenticatedDevice { device_row })
29+
}
30+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
use primitives::WalletId;
2+
use rocket::Request;
3+
use rocket::outcome::Outcome::Success;
4+
use rocket::request::{FromRequest, Outcome};
5+
use storage::models::DeviceRow;
6+
7+
use super::auth::{auth_error_outcome, authenticate, lookup_device_wallet};
8+
use crate::devices::constants::HEADER_WALLET_ID;
9+
use crate::devices::error::DeviceError;
10+
11+
// Verifies control of the device key, then resolves a wallet attached to that device.
12+
// This proves device-wallet scope, not wallet-owner intent; routes that need owner approval must also use WalletSigned<T>.
13+
pub struct AuthenticatedDeviceWallet {
14+
pub device_row: DeviceRow,
15+
pub wallet_id: i32,
16+
pub wallet_identifier: WalletId,
17+
}
18+
19+
#[rocket::async_trait]
20+
impl<'r> FromRequest<'r> for AuthenticatedDeviceWallet {
21+
type Error = String;
22+
23+
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, String> {
24+
let auth = match authenticate(req).await {
25+
Ok(auth) => auth,
26+
Err(error) => return error,
27+
};
28+
29+
let Some(wallet_id_str) = auth.wallet_id else {
30+
return auth_error_outcome(req, DeviceError::MissingHeader(HEADER_WALLET_ID), Some(&auth.device_id), None);
31+
};
32+
33+
let (device_row, wallet_row) = match lookup_device_wallet(req, &auth.device_id, &wallet_id_str).await {
34+
Ok(result) => result,
35+
Err(error) => return error,
36+
};
37+
38+
Success(AuthenticatedDeviceWallet {
39+
device_row,
40+
wallet_id: wallet_row.id,
41+
wallet_identifier: wallet_row.wallet_id.0,
42+
})
43+
}
44+
}

apps/api/src/devices/guard/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
mod auth;
2+
mod authenticated_device;
3+
mod authenticated_device_wallet;
4+
mod verified_device_id;
5+
6+
pub use authenticated_device::AuthenticatedDevice;
7+
pub use authenticated_device_wallet::AuthenticatedDeviceWallet;
8+
pub use verified_device_id::VerifiedDeviceId;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
use rocket::Request;
2+
use rocket::outcome::Outcome::Success;
3+
use rocket::request::{FromRequest, Outcome};
4+
5+
use super::auth::authenticate;
6+
7+
// Verifies the device request signature without checking the database.
8+
pub struct VerifiedDeviceId(pub String);
9+
10+
#[rocket::async_trait]
11+
impl<'r> FromRequest<'r> for VerifiedDeviceId {
12+
type Error = String;
13+
14+
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, String> {
15+
match authenticate(req).await {
16+
Ok(auth) => Success(VerifiedDeviceId(auth.device_id)),
17+
Err(error) => error,
18+
}
19+
}
20+
}

apps/api/src/devices/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,10 @@ pub async fn redeem_device_rewards_v2(
193193
request: WalletSigned<RedemptionRequest>,
194194
client: &State<Mutex<RewardsRedemptionClient>>,
195195
) -> Result<ApiResponse<RedemptionResult>, ApiError> {
196+
if !request.matches_multicoin_wallet(&device.wallet_identifier) {
197+
return Err(ApiError::BadRequest("Wallet signature mismatch".to_string()));
198+
}
199+
196200
Ok(client
197201
.lock()
198202
.await

crates/storage/src/database/wallets.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use primitives::Chain;
88

99
pub trait WalletsStore {
1010
fn get_wallet(&mut self, identifier: &str) -> Result<WalletRow, diesel::result::Error>;
11+
fn get_wallet_by_device_and_identifier(&mut self, device_id: i32, identifier: &str) -> Result<WalletRow, diesel::result::Error>;
1112
fn get_wallet_by_id(&mut self, id: i32) -> Result<WalletRow, diesel::result::Error>;
1213
fn get_wallets(&mut self, identifiers: Vec<String>) -> Result<Vec<WalletRow>, diesel::result::Error>;
1314
fn create_wallet(&mut self, wallet: NewWalletRow) -> Result<WalletRow, diesel::result::Error>;
@@ -46,6 +47,15 @@ impl WalletsStore for DatabaseClient {
4647
.first(&mut self.connection)
4748
}
4849

50+
fn get_wallet_by_device_and_identifier(&mut self, device_id: i32, identifier: &str) -> Result<WalletRow, diesel::result::Error> {
51+
wallets::table
52+
.inner_join(wallets_subscriptions::table.on(wallets_subscriptions::wallet_id.eq(wallets::id)))
53+
.filter(wallets::identifier.eq(identifier))
54+
.filter(wallets_subscriptions::device_id.eq(device_id))
55+
.select(WalletRow::as_select())
56+
.first(&mut self.connection)
57+
}
58+
4959
fn get_wallet_by_id(&mut self, id: i32) -> Result<WalletRow, diesel::result::Error> {
5060
wallets::table.filter(wallets::id.eq(id)).select(WalletRow::as_select()).first(&mut self.connection)
5161
}

crates/storage/src/repositories/wallets_repository.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::collections::{HashMap, HashSet};
77

88
pub trait WalletsRepository {
99
fn get_wallet(&mut self, identifier: &str) -> Result<WalletRow, DatabaseError>;
10+
fn get_wallet_by_device_and_identifier(&mut self, device_id: i32, identifier: &str) -> Result<WalletRow, DatabaseError>;
1011
fn get_wallet_by_id(&mut self, id: i32) -> Result<WalletRow, DatabaseError>;
1112
fn get_wallets(&mut self, identifiers: Vec<String>) -> Result<Vec<WalletRow>, DatabaseError>;
1213
fn create_wallets(&mut self, wallets: Vec<NewWalletRow>) -> Result<usize, DatabaseError>;
@@ -32,6 +33,10 @@ impl WalletsRepository for DatabaseClient {
3233
WalletsStore::get_wallet(self, identifier).or_not_found(identifier.to_string())
3334
}
3435

36+
fn get_wallet_by_device_and_identifier(&mut self, device_id: i32, identifier: &str) -> Result<WalletRow, DatabaseError> {
37+
WalletsStore::get_wallet_by_device_and_identifier(self, device_id, identifier).or_not_found(identifier.to_string())
38+
}
39+
3540
fn get_wallet_by_id(&mut self, id: i32) -> Result<WalletRow, DatabaseError> {
3641
WalletsStore::get_wallet_by_id(self, id).or_not_found_internal(id.to_string())
3742
}

0 commit comments

Comments
 (0)