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