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