1use std::{
41 fmt::Write,
42 time::{SystemTime, UNIX_EPOCH},
43};
44
45use nautilus_core::string::secret::SecretString;
46use rand::RngExt;
47use thiserror::Error;
48
49use crate::{
50 common::consts::LIGHTER_AUTH_TOKEN_MAX_TTL,
51 signing::{
52 curve::{SCALAR_BYTES, Scalar},
53 field::Fp,
54 hash::hash_to_quintic_extension,
55 schnorr::{PrivateKey, SIG_BYTES},
56 },
57};
58
59#[derive(Debug, Error, PartialEq, Eq)]
61pub enum AuthTokenError {
62 #[error("auth-token deadline {deadline} is not in the future of now {now}")]
64 DeadlineNotInFuture {
65 deadline: i64,
67 now: i64,
69 },
70 #[error("auth-token deadline {deadline} exceeds max TTL {max_ttl_secs}s from now {now}")]
72 TtlTooLarge {
73 deadline: i64,
75 now: i64,
77 max_ttl_secs: i64,
79 },
80 #[error("system clock is before UNIX epoch")]
82 ClockBeforeEpoch,
83 #[error("non-canonical Goldilocks limb at byte offset {offset}")]
91 MessageEncoding {
92 offset: usize,
94 },
95}
96
97pub const DEFAULT_AUTH_TOKEN_TTL_SECS: i64 = 7 * 60 * 60;
102
103pub fn build_auth_token_for(
117 credential: &crate::common::credential::Credential,
118) -> anyhow::Result<SecretString> {
119 let now = SystemTime::now()
120 .duration_since(UNIX_EPOCH)
121 .map_err(|_| anyhow::anyhow!("system clock is before UNIX epoch"))?
122 .as_secs();
123 let now_i64 = i64::try_from(now)
124 .map_err(|_| anyhow::anyhow!("system clock overflowed when converting to i64"))?;
125 let deadline = now_i64
126 .checked_add(DEFAULT_AUTH_TOKEN_TTL_SECS)
127 .ok_or_else(|| anyhow::anyhow!("deadline computation overflowed"))?;
128 let sk = credential.private_key()?;
129 build_auth_token(
130 deadline,
131 credential.account_index(),
132 credential.api_key_index(),
133 &sk,
134 fresh_k(),
135 )
136 .map_err(|e| anyhow::anyhow!("failed to mint Lighter auth token: {e}"))
137}
138
139#[must_use]
147pub fn fresh_k() -> Scalar {
148 let mut bytes = [0u8; SCALAR_BYTES];
149 rand::rng().fill(&mut bytes[..]);
150 Scalar::from_le_bytes_reduce(bytes)
151}
152
153pub fn build_auth_token(
165 deadline_unix_secs: i64,
166 account_index: i64,
167 api_key_index: u8,
168 sk: &PrivateKey,
169 k: Scalar,
170) -> Result<SecretString, AuthTokenError> {
171 let now = SystemTime::now()
172 .duration_since(UNIX_EPOCH)
173 .map_err(|_| AuthTokenError::ClockBeforeEpoch)?
174 .as_secs();
175 let now_i64 = i64::try_from(now).map_err(|_| AuthTokenError::ClockBeforeEpoch)?;
176 build_auth_token_at(
177 now_i64,
178 deadline_unix_secs,
179 account_index,
180 api_key_index,
181 sk,
182 k,
183 )
184}
185
186pub fn build_auth_token_at(
196 now_unix_secs: i64,
197 deadline_unix_secs: i64,
198 account_index: i64,
199 api_key_index: u8,
200 sk: &PrivateKey,
201 k: Scalar,
202) -> Result<SecretString, AuthTokenError> {
203 if deadline_unix_secs <= now_unix_secs {
204 return Err(AuthTokenError::DeadlineNotInFuture {
205 deadline: deadline_unix_secs,
206 now: now_unix_secs,
207 });
208 }
209
210 let ttl_secs = deadline_unix_secs - now_unix_secs;
211 let max_ttl_secs = i64::try_from(LIGHTER_AUTH_TOKEN_MAX_TTL.as_secs()).unwrap_or(i64::MAX);
212
213 if ttl_secs > max_ttl_secs {
214 return Err(AuthTokenError::TtlTooLarge {
215 deadline: deadline_unix_secs,
216 now: now_unix_secs,
217 max_ttl_secs,
218 });
219 }
220
221 build_auth_token_unchecked(deadline_unix_secs, account_index, api_key_index, sk, k)
222}
223
224pub fn build_auth_token_unchecked(
237 deadline_unix_secs: i64,
238 account_index: i64,
239 api_key_index: u8,
240 sk: &PrivateKey,
241 k: Scalar,
242) -> Result<SecretString, AuthTokenError> {
243 let message = auth_token_message(deadline_unix_secs, account_index, api_key_index);
244 let sig = sign_message(&message, sk, k)?;
245 Ok(format_token(&message, &sig))
246}
247
248#[must_use]
254pub fn auth_token_message(
255 deadline_unix_secs: i64,
256 account_index: i64,
257 api_key_index: u8,
258) -> String {
259 format!("{deadline_unix_secs}:{account_index}:{api_key_index}")
260}
261
262pub fn hash_auth_message(message: &str) -> Result<[u8; 40], AuthTokenError> {
277 let elems = ascii_to_fp_limbs(message.as_bytes())?;
278 Ok(hash_to_quintic_extension(&elems).to_le_bytes())
279}
280
281fn sign_message(
282 message: &str,
283 sk: &PrivateKey,
284 k: Scalar,
285) -> Result<[u8; SIG_BYTES], AuthTokenError> {
286 let elems = ascii_to_fp_limbs(message.as_bytes())?;
287 let digest = hash_to_quintic_extension(&elems);
288 Ok(sk.sign(digest, k).to_le_bytes())
289}
290
291fn ascii_to_fp_limbs(bytes: &[u8]) -> Result<Vec<Fp>, AuthTokenError> {
292 let mut out = Vec::with_capacity(bytes.len().div_ceil(8));
293 let mut i = 0;
294
295 while i < bytes.len() {
296 let end = core::cmp::min(i + 8, bytes.len());
297 let mut limb = [0u8; 8];
298 limb[..end - i].copy_from_slice(&bytes[i..end]);
299 let fp =
300 Fp::try_from_le_bytes(limb).ok_or(AuthTokenError::MessageEncoding { offset: i })?;
301 out.push(fp);
302 i = end;
303 }
304
305 Ok(out)
306}
307
308fn format_token(message: &str, sig: &[u8; SIG_BYTES]) -> SecretString {
309 let mut out = String::with_capacity(message.len() + 1 + SIG_BYTES * 2);
312 out.push_str(message);
313 out.push(':');
314 for b in sig {
315 write!(&mut out, "{b:02x}").expect("writing into String never fails");
316 }
317 out.into()
318}
319
320#[cfg(test)]
321mod tests {
322 use proptest::prelude::*;
323 use rstest::rstest;
324
325 use super::*;
326 use crate::signing::{
327 curve::SCALAR_BYTES, field::Fp5, fixtures::hex_to_array, schnorr::Signature,
328 };
329
330 fn fixed_sk() -> PrivateKey {
331 let bytes: [u8; SCALAR_BYTES] = [
334 0x0b, 0x8e, 0x0f, 0x63, 0xc2, 0x4d, 0x8b, 0xaa, 0xcd, 0x9d, 0x29, 0xad, 0x4e, 0x9a,
335 0x4b, 0x73, 0xc4, 0xa8, 0xd2, 0xbb, 0x8b, 0x16, 0xdc, 0x4f, 0xa9, 0xd7, 0xc2, 0xe1,
336 0xd3, 0xa8, 0xb1, 0xf0, 0xe8, 0xd3, 0xa4, 0xc5, 0xb6, 0xe7, 0xf0, 0x01,
337 ];
338 PrivateKey::from_le_bytes_reduce(bytes)
339 }
340
341 fn nonzero_k() -> Scalar {
342 let mut bytes = [0u8; SCALAR_BYTES];
343 bytes[0] = 0x42;
344 bytes[7] = 0x01;
345 Scalar::from_le_bytes_reduce(bytes)
346 }
347
348 #[rstest]
349 fn message_format_matches_go_reference() {
350 let m = auth_token_message(1_777_809_907, 12345, 5);
351 assert_eq!(m, "1777809907:12345:5", "was {m}");
352 }
353
354 #[rstest]
355 fn build_auth_token_smoke_test() {
356 let now = SystemTime::now()
361 .duration_since(UNIX_EPOCH)
362 .unwrap()
363 .as_secs();
364 let now_i64 = i64::try_from(now).unwrap();
365 let deadline = now_i64 + 600;
366 let account_index = 12345i64;
367 let api_key_index = 5u8;
368 let token = build_auth_token(
369 deadline,
370 account_index,
371 api_key_index,
372 &fixed_sk(),
373 nonzero_k(),
374 )
375 .expect("future deadline must sign");
376 let token = token.expose_secret();
377
378 let prefix = format!("{deadline}:{account_index}:{api_key_index}:");
379 assert!(
380 token.starts_with(&prefix),
381 "token must start with deadline:account:key:, was {token}",
382 );
383 let sig_hex = &token[prefix.len()..];
384 assert_eq!(
385 sig_hex.len(),
386 SIG_BYTES * 2,
387 "hex sig must span 160 chars, was {}",
388 sig_hex.len(),
389 );
390 }
391
392 #[rstest]
393 fn token_is_message_colon_hex_sig() {
394 let token =
395 build_auth_token_at(1_000_000, 1_000_300, 12345, 5, &fixed_sk(), nonzero_k()).unwrap();
396 let token = token.expose_secret();
397 let mut parts = token.rsplitn(2, ':');
398 let sig_hex = parts.next().expect("token must have sig component");
399 let prefix = parts.next().expect("token must have message prefix");
400
401 assert_eq!(prefix, "1000300:12345:5", "was {prefix}");
402 assert_eq!(
403 sig_hex.len(),
404 SIG_BYTES * 2,
405 "hex sig must span 160 chars, was {}",
406 sig_hex.len(),
407 );
408 assert!(
409 sig_hex
410 .chars()
411 .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()),
412 "sig must be lowercase hex, was {sig_hex}",
413 );
414 }
415
416 #[rstest]
417 fn token_signature_verifies_under_derived_pubkey() {
418 let sk = fixed_sk();
419 let pk = sk.public_key();
420 let deadline = 1_000_300;
421 let token = build_auth_token_at(1_000_000, deadline, 12345, 5, &sk, nonzero_k()).unwrap();
422
423 let (message, sig_hex) = split_token(token.expose_secret());
424 let digest_bytes = hash_auth_message(&message).expect("ASCII input must hash");
425 let digest = Fp5::try_from_le_bytes(digest_bytes).expect("digest must be canonical");
426 let sig = decode_sig(&sig_hex);
427
428 assert!(
429 pk.verify(digest, &sig),
430 "self-issued token must verify under derived pubkey",
431 );
432 }
433
434 #[rstest]
435 fn deadline_in_past_errors() {
436 let err = build_auth_token_at(1_000, 999, 1, 0, &fixed_sk(), nonzero_k())
437 .expect_err("must reject past deadline");
438 assert_eq!(
439 err,
440 AuthTokenError::DeadlineNotInFuture {
441 deadline: 999,
442 now: 1_000,
443 },
444 );
445 }
446
447 #[rstest]
448 fn deadline_equal_to_now_errors() {
449 let err = build_auth_token_at(1_000, 1_000, 1, 0, &fixed_sk(), nonzero_k())
450 .expect_err("must reject equal deadline");
451 assert_eq!(
452 err,
453 AuthTokenError::DeadlineNotInFuture {
454 deadline: 1_000,
455 now: 1_000,
456 },
457 );
458 }
459
460 #[rstest]
461 fn deadline_beyond_max_ttl_errors() {
462 let now = 1_000_000;
463 let max_ttl = i64::try_from(LIGHTER_AUTH_TOKEN_MAX_TTL.as_secs()).unwrap();
464 let deadline = now + max_ttl + 1;
465 let err = build_auth_token_at(now, deadline, 1, 0, &fixed_sk(), nonzero_k())
466 .expect_err("must reject TTL above cap");
467 assert_eq!(
468 err,
469 AuthTokenError::TtlTooLarge {
470 deadline,
471 now,
472 max_ttl_secs: max_ttl,
473 },
474 );
475 }
476
477 #[rstest]
478 fn deadline_at_max_ttl_succeeds() {
479 let now = 1_000_000;
480 let max_ttl = i64::try_from(LIGHTER_AUTH_TOKEN_MAX_TTL.as_secs()).unwrap();
481 let deadline = now + max_ttl;
482 let token = build_auth_token_at(now, deadline, 1, 0, &fixed_sk(), nonzero_k())
483 .expect("max-TTL deadline must sign");
484 let token = token.expose_secret();
485 assert!(
486 token.starts_with(&format!("{deadline}:1:0:")),
487 "was {token}"
488 );
489 }
490
491 #[rstest]
492 fn hash_input_packs_eight_bytes_per_limb() {
493 let elems = super::ascii_to_fp_limbs(b"abc").expect("ASCII input must encode");
495 assert_eq!(elems.len(), 1);
496 let limb = u64::from_le_bytes([b'a', b'b', b'c', 0, 0, 0, 0, 0]);
497 assert_eq!(elems[0].to_u64(), limb);
498
499 let elems = super::ascii_to_fp_limbs(b"abcdefghI").expect("ASCII input must encode");
501 assert_eq!(elems.len(), 2);
502 assert_eq!(elems[1].to_u64(), u64::from(b'I'));
503 }
504
505 #[rstest]
506 fn hash_input_rejects_non_canonical_limb() {
507 let mut bytes = [0xFFu8; 8];
509 bytes[7] = 0xFF;
510 let err = super::ascii_to_fp_limbs(&bytes).expect_err("must reject non-canonical");
511 assert_eq!(err, AuthTokenError::MessageEncoding { offset: 0 });
512 }
513
514 fn split_token(token: &str) -> (String, String) {
515 let mut parts = token.rsplitn(2, ':');
516 let sig_hex = parts.next().unwrap().to_string();
517 let message = parts.next().unwrap().to_string();
518 (message, sig_hex)
519 }
520
521 fn decode_sig(hex: &str) -> Signature {
522 assert_eq!(hex.len(), SIG_BYTES * 2);
523 let mut buf = [0u8; SIG_BYTES];
524 for (i, slot) in buf.iter_mut().enumerate() {
525 *slot = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).unwrap();
526 }
527 Signature::from_le_bytes_reduce(buf)
528 }
529
530 proptest! {
531 #[rstest]
536 fn prop_ascii_to_fp_limbs_round_trip(
537 input in proptest::collection::vec(0u8..=0x7F, 0..64),
538 ) {
539 let limbs = super::ascii_to_fp_limbs(&input).expect("ASCII input must encode");
540 prop_assert_eq!(limbs.len(), input.len().div_ceil(8));
541
542 let mut unpacked = Vec::with_capacity(limbs.len() * 8);
543 for fp in &limbs {
544 unpacked.extend_from_slice(&fp.to_u64().to_le_bytes());
545 }
546
547 let mut padded = input;
548 while padded.len() % 8 != 0 {
549 padded.push(0);
550 }
551 prop_assert_eq!(unpacked, padded);
552 }
553
554 #[rstest]
556 fn prop_hash_auth_message_deterministic(s in "[ -~]{0,128}") {
557 let h1 = super::hash_auth_message(&s).expect("ASCII must hash");
558 let h2 = super::hash_auth_message(&s).expect("ASCII must hash");
559 prop_assert_eq!(h1, h2);
560 }
561
562 #[rstest]
565 fn prop_self_issued_token_verifies(
566 account_index in 0i64..1_000_000_000,
567 api_key_index in 0u8..=255,
568 ttl_secs in 1i64..(LIGHTER_AUTH_TOKEN_MAX_TTL.as_secs() as i64),
569 k_seed in 1u64..u64::MAX,
570 ) {
571 let sk = fixed_sk();
572 let pk = sk.public_key();
573 let now = 1_700_000_000;
574 let deadline = now + ttl_secs;
575
576 let mut k_bytes = [0u8; SCALAR_BYTES];
577 k_bytes[..8].copy_from_slice(&k_seed.to_le_bytes());
578 let k = Scalar::from_le_bytes_reduce(k_bytes);
579 prop_assume!(!k.is_zero());
580
581 let token = build_auth_token_at(
582 now, deadline, account_index, api_key_index, &sk, k,
583 )
584 .unwrap();
585 let (message, sig_hex) = split_token(token.expose_secret());
586 let expected = format!("{deadline}:{account_index}:{api_key_index}");
587 prop_assert_eq!(&message, &expected);
588
589 let digest_bytes = hash_auth_message(&message).expect("ASCII input must hash");
590 let digest = Fp5::try_from_le_bytes(digest_bytes).unwrap();
591 let sig = decode_sig(&sig_hex);
592 prop_assert!(pk.verify(digest, &sig));
593 }
594 }
595
596 #[rstest]
600 fn oracle_auth_tokens_verify_against_our_hash() {
601 const ORACLE_JSON: &str = include_str!(concat!(
602 env!("CARGO_MANIFEST_DIR"),
603 "/test_data/signing_auth_token_oracle.json",
604 ));
605
606 #[derive(serde::Deserialize)]
607 struct File {
608 vectors: Vec<Vector>,
609 }
610
611 #[derive(serde::Deserialize)]
612 struct Vector {
613 sk: String,
614 account_index: i64,
615 api_key_index: u8,
616 deadline: i64,
617 token: String,
618 }
619
620 let suite: File = serde_json::from_str(ORACLE_JSON).expect("parse oracle");
621 assert!(!suite.vectors.is_empty(), "oracle vectors empty");
622
623 for (i, v) in suite.vectors.iter().enumerate() {
624 let sk_bytes = hex_to_array::<SCALAR_BYTES>(&v.sk);
625 let sk = PrivateKey::from_le_bytes_reduce(sk_bytes);
626 let pk = sk.public_key();
627
628 let expected_message = auth_token_message(v.deadline, v.account_index, v.api_key_index);
629 let (message, sig_hex) = split_token(&v.token);
630 assert_eq!(
631 message, expected_message,
632 "vector {i}: token prefix diverged, was {message}",
633 );
634
635 let digest_bytes = hash_auth_message(&message).expect("oracle message must hash");
636 let digest =
637 Fp5::try_from_le_bytes(digest_bytes).expect("auth-token digest must be canonical");
638 let sig = decode_sig(&sig_hex);
639 assert!(
640 pk.verify(digest, &sig),
641 "vector {i}: oracle sig must verify against our recomputed digest",
642 );
643 }
644 }
645
646 #[rstest]
647 fn fresh_k_returns_canonical_scalar() {
648 for _ in 0..16 {
651 let k = fresh_k();
652 assert!(
653 k.is_canonical(),
654 "fresh_k must return a canonical scalar, was {k:?}",
655 );
656 }
657 }
658
659 #[rstest]
660 fn fresh_k_yields_distinct_scalars() {
661 let a = fresh_k();
665 let b = fresh_k();
666 let c = fresh_k();
667 assert!(
668 !(a == b && b == c),
669 "fresh_k must vary across calls (a={a:?}, b={b:?}, c={c:?})",
670 );
671 }
672
673 #[rstest]
674 fn build_auth_token_for_round_trips_against_credential() {
675 const PRIVATE_KEY_HEX: &str =
680 "0b8e0f63c24d8baacd9d29ad4e9a4b73c4a8d2bb8b16dc4fa9d7c2e1d3a8b1f0e8d3a4c5b6e7f001";
681 let credential = crate::common::credential::Credential::new(5, PRIVATE_KEY_HEX, 12_345)
682 .expect("credential must construct");
683
684 let token = build_auth_token_for(&credential).expect("token mint must succeed");
685
686 let pk = credential.private_key().unwrap().public_key();
687 let (message, sig_hex) = token
688 .expose_secret()
689 .rsplit_once(':')
690 .expect("token must end with `:hex(sig)`");
691 let digest_bytes = hash_auth_message(message).expect("hash must succeed");
692 let digest = Fp5::try_from_le_bytes(digest_bytes).expect("digest must be canonical");
693 let sig_bytes = hex_to_array::<{ SIG_BYTES }>(sig_hex);
694 let sig = Signature::from_le_bytes_reduce(sig_bytes);
695 assert!(
696 pk.verify(digest, &sig),
697 "minted token must verify against credential public key",
698 );
699
700 let parts: Vec<&str> = message.splitn(3, ':').collect();
702 assert_eq!(parts.len(), 3);
703 assert_eq!(parts[1], "12345");
704 assert_eq!(parts[2], "5");
705 }
706}