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 smart-contract wallet
20//! address. The signature is produced by the session key.
21//!
22//! Pipeline (matching `derive_action_signing/utils.py::sign_rest_auth_header`):
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-LYRAWALLET = wallet`, `X-LYRATIMESTAMP = timestamp`,
29//!    `X-LYRASIGNATURE = 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    /// Smart-contract wallet address (`X-LYRAWALLET`).
60    pub wallet: String,
61    /// Millisecond UNIX timestamp string (`X-LYRATIMESTAMP`).
62    pub timestamp: String,
63    /// 0x-prefixed signature hex (`X-LYRASIGNATURE`).
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    /// Smart-contract wallet 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 [`AuthError::ClockBeforeEpoch`] if the system clock is invalid,
83/// or [`AuthError::SigningFailed`] when the underlying secp256k1 signer errors.
84pub fn build_rest_auth_headers(
85    wallet: &str,
86    signer: &PrivateKeySigner,
87) -> Result<AuthHeaders, AuthError> {
88    let now = utc_now_ms().map_err(|_| AuthError::ClockBeforeEpoch)?;
89    build_rest_auth_headers_at(wallet, signer, now)
90}
91
92/// Builds REST auth headers with an injected `now_ms` reference, suitable for
93/// deterministic testing.
94///
95/// # Errors
96///
97/// Returns [`AuthError::SigningFailed`] when the underlying secp256k1 signer
98/// errors.
99pub fn build_rest_auth_headers_at(
100    wallet: &str,
101    signer: &PrivateKeySigner,
102    now_ms: u64,
103) -> Result<AuthHeaders, AuthError> {
104    let timestamp = now_ms.to_string();
105    let signature = sign_message(&timestamp, signer)?;
106    Ok(AuthHeaders {
107        wallet: wallet.to_owned(),
108        timestamp,
109        signature,
110    })
111}
112
113/// Builds the WebSocket login body using the system clock.
114///
115/// # Errors
116///
117/// Returns [`AuthError::ClockBeforeEpoch`] if the system clock is invalid,
118/// or [`AuthError::SigningFailed`] when the underlying secp256k1 signer errors.
119pub fn build_ws_login(wallet: &str, signer: &PrivateKeySigner) -> Result<WsLogin, AuthError> {
120    let now = utc_now_ms().map_err(|_| AuthError::ClockBeforeEpoch)?;
121    build_ws_login_at(wallet, signer, now)
122}
123
124/// Builds the WebSocket login body with an injected `now_ms` reference.
125///
126/// # Errors
127///
128/// Returns [`AuthError::SigningFailed`] when the underlying secp256k1 signer
129/// errors.
130pub fn build_ws_login_at(
131    wallet: &str,
132    signer: &PrivateKeySigner,
133    now_ms: u64,
134) -> Result<WsLogin, AuthError> {
135    let timestamp = now_ms.to_string();
136    let signature = sign_message(&timestamp, signer)?;
137    Ok(WsLogin {
138        wallet: wallet.to_owned(),
139        timestamp,
140        signature,
141    })
142}
143
144fn sign_message(message: &str, signer: &PrivateKeySigner) -> Result<SecretString, AuthError> {
145    let signature =
146        signer
147            .sign_message_sync(message.as_bytes())
148            .map_err(|e| AuthError::SigningFailed {
149                message: e.to_string(),
150            })?;
151    Ok(SecretString::from(format!(
152        "0x{}",
153        alloy_primitives::hex::encode(signature.as_bytes())
154    )))
155}
156
157#[cfg(test)]
158mod tests {
159    use alloy_primitives::{Address, Signature, eip191_hash_message, hex};
160    use rstest::rstest;
161
162    use super::*;
163
164    const SESSION_KEY_HEX: &str =
165        "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
166    const TEST_WALLET: &str = "0x000000000000000000000000000000000000aaaa";
167
168    fn signer_address() -> Address {
169        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
170        signer.address()
171    }
172
173    #[rstest]
174    fn test_rest_headers_contain_three_fields() {
175        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
176        let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
177        let debug = format!("{headers:?}");
178
179        assert_eq!(headers.wallet, TEST_WALLET);
180        assert_eq!(headers.timestamp, "1700000000000");
181        assert!(headers.signature.expose_secret().starts_with("0x"));
182        assert_eq!(headers.signature.expose_secret().len(), 2 + 130);
183        assert!(debug.contains(REDACTED));
184        assert!(!debug.contains(headers.signature.expose_secret()));
185    }
186
187    #[rstest]
188    fn test_rest_signature_recovers_signer_address() {
189        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
190        let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
191        let raw = hex::decode(headers.signature.expose_secret().trim_start_matches("0x")).unwrap();
192        let signature = Signature::try_from(raw.as_slice()).unwrap();
193        // EIP-191 prefixed hash of the timestamp string is the digest the
194        // session key signed; recovery returns the session-key address.
195        let digest = eip191_hash_message(headers.timestamp.as_bytes());
196        let recovered = signature
197            .recover_address_from_prehash(&digest)
198            .expect("recover");
199        assert_eq!(recovered, signer_address());
200    }
201
202    #[rstest]
203    fn test_ws_login_matches_rest_signature_for_same_timestamp() {
204        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
205        let now = 1_700_000_001_234;
206        let rest = build_rest_auth_headers_at(TEST_WALLET, &signer, now).unwrap();
207        let ws = build_ws_login_at(TEST_WALLET, &signer, now).unwrap();
208        let debug = format!("{ws:?}");
209
210        assert_eq!(rest.timestamp, ws.timestamp);
211        assert_eq!(rest.signature, ws.signature);
212        assert_eq!(rest.wallet, ws.wallet);
213        assert!(debug.contains(REDACTED));
214        assert!(!debug.contains(ws.signature.expose_secret()));
215    }
216
217    #[rstest]
218    fn test_distinct_timestamps_produce_distinct_signatures() {
219        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
220        let a = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
221        let b = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_001).unwrap();
222        assert_ne!(a.signature, b.signature);
223    }
224
225    #[rstest]
226    fn test_signature_format_is_lowercase_hex() {
227        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
228        let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
229        let sig = headers.signature.expose_secret().trim_start_matches("0x");
230        assert!(
231            sig.chars()
232                .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
233            "expected lowercase hex, was {sig}",
234        );
235    }
236}