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