Skip to main content

nautilus_derive/signing/
auth.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Derive REST/WebSocket session authentication.
17//!
18//! Authenticated sessions are built from an EIP-191 `personal_sign` over the
19//! current millisecond timestamp string, plus the owner wallet
20//! address. The signature is produced by the session key.
21//!
22//! See <https://docs.derive.xyz/authentication/session-login>.
23//!
24//! 1. Render `timestamp = utc_now_ms().to_string()`.
25//! 2. Sign the bytes with EIP-191 `personal_sign(timestamp_bytes,
26//!    session_key)`. Alloy's [`SignerSync::sign_message_sync`] applies the
27//!    `\x19Ethereum Signed Message:\n<len>` prefix automatically.
28//! 3. Send headers `X-DeriveWallet = wallet`, `X-DeriveTimestamp = timestamp`,
29//!    `X-DeriveSignature = 0x-prefixed_signature_hex`.
30//!
31//! WebSocket login mirrors this with a JSON body of `{wallet, timestamp,
32//! signature}` instead of headers.
33
34use alloy::signers::{SignerSync, local::PrivateKeySigner};
35#[cfg(test)]
36use nautilus_core::string::secret::REDACTED;
37use nautilus_core::string::secret::SecretString;
38use thiserror::Error;
39
40use crate::signing::encoding::utc_now_ms;
41
42/// Errors raised while building auth headers.
43#[derive(Debug, Error)]
44pub enum AuthError {
45    /// The system clock is before the UNIX epoch.
46    #[error("system clock is before UNIX epoch")]
47    ClockBeforeEpoch,
48    /// secp256k1 signing failed.
49    #[error("signing failed: {message}")]
50    SigningFailed {
51        /// Signer error message.
52        message: String,
53    },
54}
55
56/// Headers sent with REST requests authenticated against a session key.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct AuthHeaders {
59    /// Owner's EOA or multisig address (`X-DeriveWallet`).
60    pub wallet: String,
61    /// Millisecond UNIX timestamp string (`X-DeriveTimestamp`).
62    pub timestamp: String,
63    /// 0x-prefixed signature hex (`X-DeriveSignature`).
64    pub signature: SecretString,
65}
66
67/// Body sent on the WebSocket `public/login` request.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct WsLogin {
70    /// Owner's EOA or multisig address.
71    pub wallet: String,
72    /// Millisecond UNIX timestamp string.
73    pub timestamp: String,
74    /// 0x-prefixed signature hex.
75    pub signature: SecretString,
76}
77
78/// Builds REST auth headers using the system clock as the reference time.
79///
80/// # Errors
81///
82/// Returns an error if the system clock is invalid, or if the underlying secp256k1 signer errors.
83pub fn build_rest_auth_headers(
84    wallet: &str,
85    signer: &PrivateKeySigner,
86) -> Result<AuthHeaders, AuthError> {
87    let now = utc_now_ms().map_err(|_| AuthError::ClockBeforeEpoch)?;
88    build_rest_auth_headers_at(wallet, signer, now)
89}
90
91/// Builds REST auth headers with an injected `now_ms` reference, suitable for
92/// deterministic testing.
93///
94/// # Errors
95///
96/// Returns an error if the underlying secp256k1 signer errors.
97pub fn build_rest_auth_headers_at(
98    wallet: &str,
99    signer: &PrivateKeySigner,
100    now_ms: u64,
101) -> Result<AuthHeaders, AuthError> {
102    let timestamp = now_ms.to_string();
103    let signature = sign_message(&timestamp, signer)?;
104    Ok(AuthHeaders {
105        wallet: wallet.to_owned(),
106        timestamp,
107        signature,
108    })
109}
110
111/// Builds the WebSocket login body using the system clock.
112///
113/// # Errors
114///
115/// Returns an error if the system clock is invalid, or if the underlying secp256k1 signer errors.
116pub fn build_ws_login(wallet: &str, signer: &PrivateKeySigner) -> Result<WsLogin, AuthError> {
117    let now = utc_now_ms().map_err(|_| AuthError::ClockBeforeEpoch)?;
118    build_ws_login_at(wallet, signer, now)
119}
120
121/// Builds the WebSocket login body with an injected `now_ms` reference.
122///
123/// # Errors
124///
125/// Returns an error if the underlying secp256k1 signer errors.
126pub fn build_ws_login_at(
127    wallet: &str,
128    signer: &PrivateKeySigner,
129    now_ms: u64,
130) -> Result<WsLogin, AuthError> {
131    let timestamp = now_ms.to_string();
132    let signature = sign_message(&timestamp, signer)?;
133    Ok(WsLogin {
134        wallet: wallet.to_owned(),
135        timestamp,
136        signature,
137    })
138}
139
140fn sign_message(message: &str, signer: &PrivateKeySigner) -> Result<SecretString, AuthError> {
141    let signature =
142        signer
143            .sign_message_sync(message.as_bytes())
144            .map_err(|e| AuthError::SigningFailed {
145                message: e.to_string(),
146            })?;
147
148    Ok(SecretString::from(format!(
149        "0x{}",
150        alloy_primitives::hex::encode(signature.as_bytes())
151    )))
152}
153
154#[cfg(test)]
155mod tests {
156    use alloy_primitives::{Address, Signature, eip191_hash_message, hex};
157    use rstest::rstest;
158
159    use super::*;
160
161    const SESSION_KEY_HEX: &str =
162        "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
163    const TEST_WALLET: &str = "0x000000000000000000000000000000000000aaaa";
164
165    fn signer_address() -> Address {
166        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
167        signer.address()
168    }
169
170    #[rstest]
171    fn test_rest_headers_contain_three_fields() {
172        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
173        let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
174        let debug = format!("{headers:?}");
175
176        assert_eq!(headers.wallet, TEST_WALLET);
177        assert_eq!(headers.timestamp, "1700000000000");
178        assert!(headers.signature.expose_secret().starts_with("0x"));
179        assert_eq!(headers.signature.expose_secret().len(), 2 + 130);
180        assert!(debug.contains(REDACTED));
181        assert!(!debug.contains(headers.signature.expose_secret()));
182    }
183
184    #[rstest]
185    fn test_rest_signature_recovers_signer_address() {
186        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
187        let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
188        let raw = hex::decode(headers.signature.expose_secret().trim_start_matches("0x")).unwrap();
189        let signature = Signature::try_from(raw.as_slice()).unwrap();
190        // EIP-191 prefixed hash of the timestamp string is the digest the
191        // session key signed; recovery returns the session-key address.
192        let digest = eip191_hash_message(headers.timestamp.as_bytes());
193        let recovered = signature
194            .recover_address_from_prehash(&digest)
195            .expect("recover");
196        assert_eq!(recovered, signer_address());
197    }
198
199    #[rstest]
200    fn test_ws_login_matches_rest_signature_for_same_timestamp() {
201        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
202        let now = 1_700_000_001_234;
203        let rest = build_rest_auth_headers_at(TEST_WALLET, &signer, now).unwrap();
204        let ws = build_ws_login_at(TEST_WALLET, &signer, now).unwrap();
205        let debug = format!("{ws:?}");
206
207        assert_eq!(rest.timestamp, ws.timestamp);
208        assert_eq!(rest.signature, ws.signature);
209        assert_eq!(rest.wallet, ws.wallet);
210        assert!(debug.contains(REDACTED));
211        assert!(!debug.contains(ws.signature.expose_secret()));
212    }
213
214    #[rstest]
215    fn test_distinct_timestamps_produce_distinct_signatures() {
216        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
217        let a = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
218        let b = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_001).unwrap();
219        assert_ne!(a.signature, b.signature);
220    }
221
222    #[rstest]
223    fn test_signature_format_is_lowercase_hex() {
224        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
225        let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
226        let sig = headers.signature.expose_secret().trim_start_matches("0x");
227        assert!(
228            sig.chars()
229                .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
230            "expected lowercase hex, was {sig}",
231        );
232    }
233}