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};
35use thiserror::Error;
36
37use crate::signing::encoding::utc_now_ms;
38
39/// Errors raised while building auth headers.
40#[derive(Debug, Error)]
41pub enum AuthError {
42    /// The system clock is before the UNIX epoch.
43    #[error("system clock is before UNIX epoch")]
44    ClockBeforeEpoch,
45    /// secp256k1 signing failed.
46    #[error("signing failed: {message}")]
47    SigningFailed {
48        /// Signer error message.
49        message: String,
50    },
51}
52
53/// Headers sent with REST requests authenticated against a session key.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct AuthHeaders {
56    /// Smart-contract wallet address (`X-LYRAWALLET`).
57    pub wallet: String,
58    /// Millisecond UNIX timestamp string (`X-LYRATIMESTAMP`).
59    pub timestamp: String,
60    /// 0x-prefixed signature hex (`X-LYRASIGNATURE`).
61    pub signature: String,
62}
63
64/// Body sent on the WebSocket `public/login` request.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct WsLogin {
67    /// Smart-contract wallet address.
68    pub wallet: String,
69    /// Millisecond UNIX timestamp string.
70    pub timestamp: String,
71    /// 0x-prefixed signature hex.
72    pub signature: String,
73}
74
75/// Builds REST auth headers using the system clock as the reference time.
76///
77/// # Errors
78///
79/// Returns [`AuthError::ClockBeforeEpoch`] if the system clock is invalid,
80/// or [`AuthError::SigningFailed`] when the underlying secp256k1 signer errors.
81pub fn build_rest_auth_headers(
82    wallet: &str,
83    signer: &PrivateKeySigner,
84) -> Result<AuthHeaders, AuthError> {
85    let now = utc_now_ms().map_err(|_| AuthError::ClockBeforeEpoch)?;
86    build_rest_auth_headers_at(wallet, signer, now)
87}
88
89/// Builds REST auth headers with an injected `now_ms` reference, suitable for
90/// deterministic testing.
91///
92/// # Errors
93///
94/// Returns [`AuthError::SigningFailed`] when the underlying secp256k1 signer
95/// errors.
96pub fn build_rest_auth_headers_at(
97    wallet: &str,
98    signer: &PrivateKeySigner,
99    now_ms: u64,
100) -> Result<AuthHeaders, AuthError> {
101    let timestamp = now_ms.to_string();
102    let signature = sign_message(&timestamp, signer)?;
103    Ok(AuthHeaders {
104        wallet: wallet.to_owned(),
105        timestamp,
106        signature,
107    })
108}
109
110/// Builds the WebSocket login body using the system clock.
111///
112/// # Errors
113///
114/// Returns [`AuthError::ClockBeforeEpoch`] if the system clock is invalid,
115/// or [`AuthError::SigningFailed`] when 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 [`AuthError::SigningFailed`] when the underlying secp256k1 signer
126/// errors.
127pub fn build_ws_login_at(
128    wallet: &str,
129    signer: &PrivateKeySigner,
130    now_ms: u64,
131) -> Result<WsLogin, AuthError> {
132    let timestamp = now_ms.to_string();
133    let signature = sign_message(&timestamp, signer)?;
134    Ok(WsLogin {
135        wallet: wallet.to_owned(),
136        timestamp,
137        signature,
138    })
139}
140
141fn sign_message(message: &str, signer: &PrivateKeySigner) -> Result<String, AuthError> {
142    let signature =
143        signer
144            .sign_message_sync(message.as_bytes())
145            .map_err(|e| AuthError::SigningFailed {
146                message: e.to_string(),
147            })?;
148    Ok(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        assert_eq!(headers.wallet, TEST_WALLET);
175        assert_eq!(headers.timestamp, "1700000000000");
176        assert!(headers.signature.starts_with("0x"));
177        assert_eq!(headers.signature.len(), 2 + 130);
178    }
179
180    #[rstest]
181    fn test_rest_signature_recovers_signer_address() {
182        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
183        let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
184        let raw = hex::decode(headers.signature.trim_start_matches("0x")).unwrap();
185        let signature = Signature::try_from(raw.as_slice()).unwrap();
186        // EIP-191 prefixed hash of the timestamp string is the digest the
187        // session key signed; recovery returns the session-key address.
188        let digest = eip191_hash_message(headers.timestamp.as_bytes());
189        let recovered = signature
190            .recover_address_from_prehash(&digest)
191            .expect("recover");
192        assert_eq!(recovered, signer_address());
193    }
194
195    #[rstest]
196    fn test_ws_login_matches_rest_signature_for_same_timestamp() {
197        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
198        let now = 1_700_000_001_234;
199        let rest = build_rest_auth_headers_at(TEST_WALLET, &signer, now).unwrap();
200        let ws = build_ws_login_at(TEST_WALLET, &signer, now).unwrap();
201        assert_eq!(rest.timestamp, ws.timestamp);
202        assert_eq!(rest.signature, ws.signature);
203        assert_eq!(rest.wallet, ws.wallet);
204    }
205
206    #[rstest]
207    fn test_distinct_timestamps_produce_distinct_signatures() {
208        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
209        let a = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
210        let b = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_001).unwrap();
211        assert_ne!(a.signature, b.signature);
212    }
213
214    #[rstest]
215    fn test_signature_format_is_lowercase_hex() {
216        let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
217        let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
218        let sig = headers.signature.trim_start_matches("0x");
219        assert!(
220            sig.chars()
221                .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
222            "expected lowercase hex, was {sig}",
223        );
224    }
225}