Skip to main content

nautilus_lighter/common/
credential.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//! Lighter credential storage and resolution.
17//!
18//! Lighter signs L2 transactions with Schnorr signatures over the ecgfp5 curve
19//! (Goldilocks quintic extension field) and Poseidon2 hashing. The cryptographic
20//! primitives live in [`crate::signing`]; this module only handles credential
21//! plumbing (private key bytes, account index, API key index, env-var resolution).
22
23use std::{fmt::Debug, str};
24
25use anyhow::Context;
26use nautilus_core::{
27    env::get_or_env_var_opt,
28    hex,
29    string::secret::{REDACTED, mask_api_key},
30};
31use zeroize::ZeroizeOnDrop;
32
33use crate::{
34    common::enums::LighterEnvironment,
35    signing::{curve::SCALAR_BYTES, schnorr::PrivateKey},
36};
37
38const LIGHTER_API_KEY_INDEX_VAR: &str = "LIGHTER_API_KEY_INDEX";
39const LIGHTER_API_SECRET_VAR: &str = "LIGHTER_API_SECRET";
40const LIGHTER_ACCOUNT_INDEX_VAR: &str = "LIGHTER_ACCOUNT_INDEX";
41const LIGHTER_TESTNET_API_KEY_INDEX_VAR: &str = "LIGHTER_TESTNET_API_KEY_INDEX";
42const LIGHTER_TESTNET_API_SECRET_VAR: &str = "LIGHTER_TESTNET_API_SECRET";
43const LIGHTER_TESTNET_ACCOUNT_INDEX_VAR: &str = "LIGHTER_TESTNET_ACCOUNT_INDEX";
44
45/// Environment variable names for Lighter credentials.
46///
47/// Returns `(api_key_index_var, api_secret_var, account_index_var)`. The
48/// `api_key_index_var` holds the per-account API key slot (0..=254), the
49/// `api_secret_var` holds the hex-encoded private key, and the
50/// `account_index_var` holds the account number assigned at registration.
51#[must_use]
52pub const fn credential_env_vars(
53    environment: LighterEnvironment,
54) -> (&'static str, &'static str, &'static str) {
55    match environment {
56        LighterEnvironment::Mainnet => (
57            LIGHTER_API_KEY_INDEX_VAR,
58            LIGHTER_API_SECRET_VAR,
59            LIGHTER_ACCOUNT_INDEX_VAR,
60        ),
61        LighterEnvironment::Testnet => (
62            LIGHTER_TESTNET_API_KEY_INDEX_VAR,
63            LIGHTER_TESTNET_API_SECRET_VAR,
64            LIGHTER_TESTNET_ACCOUNT_INDEX_VAR,
65        ),
66    }
67}
68
69/// Lighter API credentials required for authenticated REST, private WebSocket,
70/// and L2 transaction signing.
71///
72/// Lighter identifies API keys by numeric index. The API private key signs both
73/// auth tokens and L2 transactions for `(account_index, api_key_index)`.
74#[derive(Clone, ZeroizeOnDrop)]
75pub struct Credential {
76    api_key_index: u8,
77    account_index: i64,
78    api_secret: Box<[u8]>,
79}
80
81impl Debug for Credential {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct(stringify!(Credential))
84            .field("api_key_index", &self.api_key_index)
85            .field("account_index", &self.account_index)
86            .field("api_secret", &REDACTED)
87            .finish()
88    }
89}
90
91impl Credential {
92    /// Creates a new [`Credential`] instance from a key index, private key, and
93    /// account index.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if `account_index` exceeds the signed range used by the
98    /// Lighter signer or if `api_secret` is not a 40-byte hex private key.
99    pub fn new(
100        api_key_index: u8,
101        api_secret: impl Into<String>,
102        account_index: u64,
103    ) -> anyhow::Result<Self> {
104        let api_key_index = ensure_api_key_index(api_key_index)?;
105        let account_index = i64::try_from(account_index)
106            .context("Lighter account index exceeds signed 64-bit range")?;
107        let credential = Self {
108            api_key_index,
109            account_index,
110            api_secret: api_secret.into().into_bytes().into_boxed_slice(),
111        };
112        credential.private_key()?;
113        Ok(credential)
114    }
115
116    /// Resolves credentials from provided config values or environment
117    /// variables.
118    ///
119    /// Config values take precedence, but a blank or whitespace-only
120    /// `private_key` falls back to the environment variable. Environment
121    /// variables follow [`credential_env_vars`]. `LIGHTER_API_KEY_INDEX` is
122    /// the per-account API key slot (0..=254), separate from any hex public
123    /// key the venue reports for that slot.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if any resolved numeric field cannot be parsed, if the
128    /// account index exceeds the signed range, or if the API private key is not
129    /// valid 40-byte hex.
130    pub fn resolve(
131        private_key: Option<String>,
132        account_index: Option<u64>,
133        api_key_index: Option<u8>,
134        environment: LighterEnvironment,
135    ) -> anyhow::Result<Option<Self>> {
136        let (api_key_var, api_secret_var, account_index_var) = credential_env_vars(environment);
137
138        let api_key_index = resolve_api_key_index(api_key_index, api_key_var)?;
139        let account_index = resolve_account_index(account_index, account_index_var)?;
140        let api_secret =
141            get_or_env_var_opt(private_key.filter(|s| !s.trim().is_empty()), api_secret_var)
142                .filter(|s| !s.trim().is_empty());
143
144        credential_from_resolved_values(
145            api_key_index,
146            account_index,
147            api_secret,
148            api_key_var,
149            api_secret_var,
150            account_index_var,
151        )
152    }
153
154    /// Returns the Lighter API key index.
155    #[must_use]
156    pub const fn api_key_index(&self) -> u8 {
157        self.api_key_index
158    }
159
160    /// Returns the Lighter account index.
161    #[must_use]
162    pub const fn account_index(&self) -> i64 {
163        self.account_index
164    }
165
166    /// Decodes the API private key for Lighter signing.
167    ///
168    /// # Errors
169    ///
170    /// Returns an error if the secret is not 40-byte hex, with or without a
171    /// `0x` prefix.
172    pub fn private_key(&self) -> anyhow::Result<PrivateKey> {
173        let mut bytes = [0u8; SCALAR_BYTES];
174        let secret =
175            str::from_utf8(&self.api_secret).context("Lighter API secret must be UTF-8")?;
176        let decoded = decode_private_key_hex(secret)?;
177        bytes.copy_from_slice(&decoded);
178        Ok(PrivateKey::from_le_bytes_reduce(bytes))
179    }
180}
181
182/// Replaces any `auth=<token>` substring with a masked token for logs.
183#[must_use]
184pub(crate) fn scrub_auth(text: &str) -> String {
185    let needle = "auth=";
186    if !text.contains(needle) {
187        return text.to_string();
188    }
189
190    let mut out = String::with_capacity(text.len());
191    let mut idx = 0;
192    while idx < text.len() {
193        if let Some(start) = text[idx..].find(needle) {
194            let abs_start = idx + start + needle.len();
195            out.push_str(&text[idx..abs_start]);
196            let end = text[abs_start..]
197                .find(|c: char| c == '&' || c.is_whitespace())
198                .map_or(text.len(), |p| abs_start + p);
199            let token = &text[abs_start..end];
200            out.push_str(&mask_api_key(token));
201            idx = end;
202        } else {
203            out.push_str(&text[idx..]);
204            break;
205        }
206    }
207    out
208}
209
210fn credential_from_resolved_values(
211    api_key_index: Option<u8>,
212    account_index: Option<u64>,
213    api_secret: Option<String>,
214    api_key_var: &str,
215    api_secret_var: &str,
216    account_index_var: &str,
217) -> anyhow::Result<Option<Credential>> {
218    match (api_key_index, account_index, api_secret) {
219        (Some(api_key_index), Some(account_index), Some(api_secret)) => Ok(Some(Credential::new(
220            api_key_index,
221            api_secret,
222            account_index,
223        )?)),
224        (None, None, None) => Ok(None),
225        _ => anyhow::bail!(
226            "incomplete Lighter credentials: set {api_key_var}, {api_secret_var}, and {account_index_var}"
227        ),
228    }
229}
230
231fn resolve_api_key_index(value: Option<u8>, env_var: &str) -> anyhow::Result<Option<u8>> {
232    match value {
233        Some(value) => ensure_api_key_index(value).map(Some),
234        None => get_or_env_var_opt(None::<String>, env_var)
235            .filter(|s| !s.trim().is_empty())
236            .map(|s| parse_api_key_index(&s, env_var))
237            .transpose(),
238    }
239}
240
241fn resolve_account_index(value: Option<u64>, env_var: &str) -> anyhow::Result<Option<u64>> {
242    match value {
243        Some(value) => Ok(Some(value)),
244        None => get_or_env_var_opt(None::<String>, env_var)
245            .filter(|s| !s.trim().is_empty())
246            .map(|s| {
247                s.trim()
248                    .parse::<u64>()
249                    .with_context(|| format!("{env_var} must be an unsigned integer"))
250            })
251            .transpose(),
252    }
253}
254
255fn parse_api_key_index(value: &str, env_var: &str) -> anyhow::Result<u8> {
256    let index = value
257        .trim()
258        .parse::<u8>()
259        .with_context(|| format!("{env_var} must be an API key index in 0..=254"))?;
260    ensure_api_key_index(index)
261}
262
263fn ensure_api_key_index(value: u8) -> anyhow::Result<u8> {
264    anyhow::ensure!(value <= 254, "Lighter API key index must be in 0..=254");
265    Ok(value)
266}
267
268fn decode_private_key_hex(value: &str) -> anyhow::Result<Vec<u8>> {
269    let value = value.trim();
270    let hex = value
271        .strip_prefix("0x")
272        .or_else(|| value.strip_prefix("0X"))
273        .unwrap_or(value);
274    let bytes = hex::decode(hex).context("Lighter API secret must be valid hex")?;
275    anyhow::ensure!(
276        bytes.len() == SCALAR_BYTES,
277        "Lighter API secret must be a 40-byte hex private key"
278    );
279    Ok(bytes)
280}
281
282#[cfg(test)]
283mod tests {
284    use rstest::rstest;
285
286    use super::*;
287
288    const PRIVATE_KEY_HEX: &str =
289        "0b8e0f63c24d8baacd9d29ad4e9a4b73c4a8d2bb8b16dc4fa9d7c2e1d3a8b1f0e8d3a4c5b6e7f001";
290
291    #[rstest]
292    fn test_credential_env_vars_mainnet() {
293        assert_eq!(
294            credential_env_vars(LighterEnvironment::Mainnet),
295            (
296                "LIGHTER_API_KEY_INDEX",
297                "LIGHTER_API_SECRET",
298                "LIGHTER_ACCOUNT_INDEX"
299            ),
300        );
301    }
302
303    #[rstest]
304    fn test_credential_env_vars_testnet() {
305        assert_eq!(
306            credential_env_vars(LighterEnvironment::Testnet),
307            (
308                "LIGHTER_TESTNET_API_KEY_INDEX",
309                "LIGHTER_TESTNET_API_SECRET",
310                "LIGHTER_TESTNET_ACCOUNT_INDEX"
311            ),
312        );
313    }
314
315    #[rstest]
316    fn test_resolve_with_config_values() {
317        let credential = Credential::resolve(
318            Some(PRIVATE_KEY_HEX.to_string()),
319            Some(12_345),
320            Some(4),
321            LighterEnvironment::Mainnet,
322        )
323        .unwrap()
324        .unwrap();
325
326        assert_eq!(credential.api_key_index(), 4);
327        assert_eq!(credential.account_index(), 12_345);
328        assert!(credential.private_key().is_ok());
329    }
330
331    #[rstest]
332    fn test_credential_from_resolved_values() {
333        let credential = credential_from_resolved_values(
334            Some(4),
335            Some(12_345),
336            Some(PRIVATE_KEY_HEX.to_string()),
337            LIGHTER_API_KEY_INDEX_VAR,
338            LIGHTER_API_SECRET_VAR,
339            LIGHTER_ACCOUNT_INDEX_VAR,
340        )
341        .unwrap()
342        .unwrap();
343
344        assert_eq!(credential.api_key_index(), 4);
345        assert_eq!(credential.account_index(), 12_345);
346        assert!(credential.private_key().is_ok());
347    }
348
349    #[rstest]
350    fn test_credential_from_resolved_values_rejects_partial_values() {
351        let err = credential_from_resolved_values(
352            Some(4),
353            None,
354            Some(PRIVATE_KEY_HEX.to_string()),
355            LIGHTER_API_KEY_INDEX_VAR,
356            LIGHTER_API_SECRET_VAR,
357            LIGHTER_ACCOUNT_INDEX_VAR,
358        )
359        .unwrap_err();
360
361        assert!(err.to_string().contains("incomplete Lighter credentials"));
362    }
363
364    #[rstest]
365    fn test_resolve_rejects_invalid_api_secret() {
366        let err = Credential::resolve(
367            Some("not-hex".to_string()),
368            Some(12_345),
369            Some(4),
370            LighterEnvironment::Mainnet,
371        )
372        .unwrap_err();
373
374        assert!(err.to_string().contains("valid hex"));
375    }
376
377    #[rstest]
378    fn test_private_key_accepts_prefixed_hex() {
379        let lower_prefixed = format!("0x{PRIVATE_KEY_HEX}");
380        let upper_prefixed = format!("0X{PRIVATE_KEY_HEX}");
381
382        let lower = Credential::new(4, lower_prefixed, 12_345).unwrap();
383        let upper = Credential::new(4, upper_prefixed, 12_345).unwrap();
384
385        assert!(lower.private_key().is_ok());
386        assert!(upper.private_key().is_ok());
387    }
388
389    #[rstest]
390    fn test_debug_redacts_api_secret() {
391        let credential = Credential::new(4, PRIVATE_KEY_HEX, 12_345).unwrap();
392
393        let dbg_out = format!("{credential:?}");
394
395        assert!(dbg_out.contains(REDACTED));
396        assert!(!dbg_out.contains(PRIVATE_KEY_HEX));
397    }
398
399    #[rstest]
400    #[case::no_auth("no auth here", "no auth here")]
401    #[case::short_token("auth=abc", "auth=***")]
402    #[case::long_token("auth=abcdefghijklmnop", "auth=abcd...mnop")]
403    #[case::url_with_ampersand("url?auth=abcdefghijklmnop&other=x", "url?auth=abcd...mnop&other=x")]
404    #[case::empty_token_value("url?auth=&other=x", "url?auth=&other=x")]
405    #[case::multiple_auth(
406        "first auth=token1 mid auth=token2 end",
407        "first auth=****** mid auth=****** end"
408    )]
409    #[case::trailing_whitespace("auth=tok end", "auth=*** end")]
410    #[case::newline_boundary(
411        "first auth=token1\nsecond auth=token2",
412        "first auth=******\nsecond auth=******"
413    )]
414    fn scrub_auth_redacts_token(#[case] input: &str, #[case] expected: &str) {
415        assert_eq!(scrub_auth(input), expected);
416    }
417
418    #[rstest]
419    fn scrub_auth_empty_input_returns_empty() {
420        assert_eq!(scrub_auth(""), "");
421    }
422
423    // Tests that observe the env-var fallback live in the workspace `serial_tests`
424    // group (see `.config/nextest.toml`) so env-var mutation is pinned to a single
425    // thread.
426    #[allow(unsafe_code)] // env-var mutation in tests; restored via `EnvGuard`.
427    mod serial_tests {
428        use super::*;
429
430        const LIGHTER_ENV_VARS: &[&str] = &[
431            "LIGHTER_API_KEY_INDEX",
432            "LIGHTER_API_SECRET",
433            "LIGHTER_ACCOUNT_INDEX",
434            "LIGHTER_TESTNET_API_KEY_INDEX",
435            "LIGHTER_TESTNET_API_SECRET",
436            "LIGHTER_TESTNET_ACCOUNT_INDEX",
437        ];
438
439        /// Snapshots and clears the Lighter credential env vars, restoring the
440        /// original values on drop.
441        struct EnvGuard {
442            saved: Vec<(&'static str, Option<String>)>,
443        }
444
445        impl EnvGuard {
446            fn clear_lighter() -> Self {
447                let saved = LIGHTER_ENV_VARS
448                    .iter()
449                    .map(|&name| (name, std::env::var(name).ok()))
450                    .collect::<Vec<_>>();
451                for &(name, _) in &saved {
452                    // SAFETY: the `serial_tests` nextest group serializes these
453                    // tests, and no other lighter test reads or writes the
454                    // LIGHTER_* env vars concurrently.
455                    unsafe { std::env::remove_var(name) };
456                }
457                Self { saved }
458            }
459        }
460
461        impl Drop for EnvGuard {
462            fn drop(&mut self) {
463                for (name, original) in &self.saved {
464                    match original {
465                        // SAFETY: see `EnvGuard::clear_lighter`.
466                        Some(value) => unsafe { std::env::set_var(name, value) },
467                        None => unsafe { std::env::remove_var(name) },
468                    }
469                }
470            }
471        }
472
473        #[rstest]
474        #[case::empty("")]
475        #[case::whitespace("   ")]
476        fn resolve_blank_private_key_falls_back_to_env_secret(#[case] blank: &str) {
477            let _guard = EnvGuard::clear_lighter();
478            // SAFETY: see `EnvGuard::clear_lighter`; the guard restores on drop.
479            unsafe { std::env::set_var("LIGHTER_API_SECRET", PRIVATE_KEY_HEX) };
480
481            let credential = Credential::resolve(
482                Some(blank.to_string()),
483                Some(12_345),
484                Some(4),
485                LighterEnvironment::Mainnet,
486            )
487            .unwrap()
488            .unwrap();
489
490            let from_env = Credential::new(4, PRIVATE_KEY_HEX, 12_345).unwrap();
491            assert_eq!(
492                credential.private_key().unwrap().to_le_bytes(),
493                from_env.private_key().unwrap().to_le_bytes(),
494            );
495        }
496
497        #[rstest]
498        fn resolve_blank_private_key_without_env_returns_none() {
499            let _guard = EnvGuard::clear_lighter();
500
501            let resolved = Credential::resolve(
502                Some("   ".to_string()),
503                None,
504                None,
505                LighterEnvironment::Mainnet,
506            )
507            .unwrap();
508
509            assert!(resolved.is_none());
510        }
511
512        #[rstest]
513        fn resolve_blank_env_secret_returns_none() {
514            let _guard = EnvGuard::clear_lighter();
515            // SAFETY: see `EnvGuard::clear_lighter`; the guard restores on drop.
516            unsafe { std::env::set_var("LIGHTER_API_SECRET", "   ") };
517
518            let resolved =
519                Credential::resolve(None, None, None, LighterEnvironment::Mainnet).unwrap();
520
521            assert!(resolved.is_none());
522        }
523
524        #[rstest]
525        fn resolve_prefers_non_blank_config_over_env_secret() {
526            let _guard = EnvGuard::clear_lighter();
527            // An invalid env secret: resolution succeeds only if the config value wins.
528            // SAFETY: see `EnvGuard::clear_lighter`.
529            unsafe { std::env::set_var("LIGHTER_API_SECRET", "not-hex") };
530
531            let credential = Credential::resolve(
532                Some(PRIVATE_KEY_HEX.to_string()),
533                Some(12_345),
534                Some(4),
535                LighterEnvironment::Mainnet,
536            )
537            .unwrap()
538            .unwrap();
539
540            assert!(credential.private_key().is_ok());
541        }
542    }
543}