Skip to main content

nautilus_lighter/signing/
auth_token.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 REST/WebSocket auth-token builder.
17//!
18//! The Lighter venue authenticates long-running clients against a Schnorr
19//! signature over an ASCII message describing `(deadline, account_index,
20//! api_key_index)`. The signed string is sent verbatim in the `Authorization`
21//! header and on the WebSocket subscribe handshake.
22//!
23//! Pipeline (matching the Go reference `ConstructAuthToken`):
24//!
25//! 1. Render `message = "{deadline}:{account_index}:{api_key_index}"`.
26//! 2. Split the ASCII bytes into 8-byte little-endian chunks, zero-padding the
27//!    final chunk if needed, and decode each chunk as a canonical Goldilocks
28//!    `Fp` element.
29//! 3. Run [`hash_to_quintic_extension`] over the resulting `[Fp]` to derive a
30//!    single `Fp5` digest.
31//! 4. Sign with the supplied `(sk, k)` using the standard Schnorr binding.
32//! 5. Concatenate `"{message}:{hex(sig)}"` where `sig` is the canonical
33//!    80-byte `s_le || e_le` Schnorr layout encoded as lowercase hex.
34//!
35//! The hash path here is not the body-element pipeline `tx::compute_tx_hash`
36//! uses; the auth token treats the message as opaque ASCII and packs it into
37//! limbs by raw little-endian bytes, while transactions encode each field as
38//! an `Fp`-domain integer.
39
40use 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/// Errors raised by [`build_auth_token`].
60#[derive(Debug, Error, PartialEq, Eq)]
61pub enum AuthTokenError {
62    /// The supplied deadline is at or before `now`.
63    #[error("auth-token deadline {deadline} is not in the future of now {now}")]
64    DeadlineNotInFuture {
65        /// Caller-supplied deadline (UNIX seconds).
66        deadline: i64,
67        /// Reference `now` (UNIX seconds).
68        now: i64,
69    },
70    /// The supplied deadline exceeds the venue's maximum TTL.
71    #[error("auth-token deadline {deadline} exceeds max TTL {max_ttl_secs}s from now {now}")]
72    TtlTooLarge {
73        /// Caller-supplied deadline (UNIX seconds).
74        deadline: i64,
75        /// Reference `now` (UNIX seconds).
76        now: i64,
77        /// Configured maximum TTL in seconds.
78        max_ttl_secs: i64,
79    },
80    /// The system clock is before the UNIX epoch.
81    #[error("system clock is before UNIX epoch")]
82    ClockBeforeEpoch,
83    /// An 8-byte chunk of the message decoded to a non-canonical Goldilocks
84    /// element (`>= p`).
85    ///
86    /// Cannot occur for the auth-token format (`"{deadline}:{account}:{key}"`)
87    /// since every byte is ASCII; surfaced as a typed error rather than a
88    /// panic so callers of [`hash_auth_message`] with arbitrary input get a
89    /// recoverable failure.
90    #[error("non-canonical Goldilocks limb at byte offset {offset}")]
91    MessageEncoding {
92        /// Byte offset of the offending 8-byte chunk in the input.
93        offset: usize,
94    },
95}
96
97/// Default deadline applied by [`build_auth_token_for`] when the caller does
98/// not supply one: 7 hours from the current wall clock. Sits inside the
99/// venue's [`LIGHTER_AUTH_TOKEN_MAX_TTL`] (8 hours) with an hour of head room
100/// so a long-running session can pre-fetch and rotate before expiry.
101pub const DEFAULT_AUTH_TOKEN_TTL_SECS: i64 = 7 * 60 * 60;
102
103/// Mint an auth token from a [`crate::common::credential::Credential`] using
104/// the default 7-hour TTL and a fresh CSPRNG nonce.
105///
106/// The token format matches the Go reference's `ConstructAuthToken`. The
107/// returned string is the value the WebSocket subscribe handshake sends in
108/// the `auth` field of an `account_*` channel subscription.
109///
110/// # Errors
111///
112/// Returns an error if the secret cannot be decoded by
113/// [`crate::common::credential::Credential::private_key`], or if [`build_auth_token`] fails
114/// (clock-before-epoch or, hypothetically, a breach caused by its own deadline validation).
115pub 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/// Draws a fresh canonical [`Scalar`] from the thread-local CSPRNG suitable
139/// for the per-signature `k` nonce.
140///
141/// The Schnorr binding requires `k` to be drawn from a cryptographic RNG and
142/// used at most once per signature; see [`PrivateKey::sign`] for the full
143/// contract. The 40-byte draw is reduced modulo the curve order, so the
144/// returned scalar is always canonical.
145#[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
152/// Build a Lighter auth token using the system clock as the `now` reference.
153///
154/// Validates the deadline against [`LIGHTER_AUTH_TOKEN_MAX_TTL`]. See
155/// [`build_auth_token_at`] for an injectable-`now` variant suitable for tests.
156///
157/// # Errors
158///
159/// Returns an error if `deadline_unix_secs <= now`, if it exceeds the venue cap, or if the system
160/// clock predates the UNIX epoch.
161pub 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
183/// Variant of [`build_auth_token`] accepting an explicit `now_unix_secs`.
184///
185/// Pure of `SystemTime`, so callers can drive the validation deterministically
186/// from a wall-clock provider or a test fixture. Same error semantics as
187/// [`build_auth_token`].
188///
189/// # Errors
190///
191/// Returns an error if [`build_auth_token`] fails.
192pub 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
221/// Sign the auth-token message without TTL validation.
222///
223/// Public so tests and oracle round-trips can produce tokens whose deadline
224/// would otherwise trip the venue cap. Production callers should use
225/// [`build_auth_token`] or [`build_auth_token_at`].
226///
227/// # Errors
228///
229/// Returns an error if the rendered message contains an 8-byte chunk that does not decode as a
230/// canonical Goldilocks element. The auth-token format keeps every byte ASCII, so this case is
231/// unreachable for the production callers.
232pub 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/// ASCII auth-token message: `"{deadline}:{account}:{api_key}"`.
245///
246/// Public for the rare caller that needs to recompute the signed preimage
247/// (e.g., to verify a token against a known public key). The message format
248/// matches the Go reference verbatim.
249#[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
258/// Hash the auth-token ASCII message to its 40-byte `Fp5` digest.
259///
260/// Splits the bytes into 8-byte little-endian Goldilocks limbs (zero-padding
261/// the trailing chunk) and runs [`hash_to_quintic_extension`] over the limbs.
262/// Returns the canonical 40-byte little-endian encoding the Schnorr binding
263/// signs over.
264///
265/// # Errors
266///
267/// Returns an error if any 8-byte chunk decodes to a non-canonical Goldilocks element. The
268/// auth-token format is ASCII (`'0'..='9'` and `':'`), so every byte sits in `0..=0x3A` and the
269/// case is unreachable for tokens emitted by [`auth_token_message`]; the error path exists for
270/// callers that pass arbitrary preimages.
271pub 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    // Lowercase, no `0x` prefix: matches `ethCommon.Bytes2Hex` in the Go
305    // reference, which is what the venue's REST/WS handshake expects.
306    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        // Same seed bytes as the tx-oracle fixture; keeps the auth-token
327        // tests self-contained without piggybacking on the tx fixture file.
328        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        // Smoke-test the system-clock variant: seed a deadline 600s ahead of
352        // wall-clock now and verify the token's structural shape. The signing
353        // pipeline is exercised by the *_at variant; this test just gates the
354        // SystemTime plumbing and Result threading.
355        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        // "abc" -> single limb [0x61, 0x62, 0x63, 0, 0, 0, 0, 0].
489        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        // 9 bytes spill into two limbs: first full, second carries one byte.
495        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        // u64::MAX > MODULUS so the 8-byte chunk is non-canonical.
503        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        /// Any ASCII (high-bit-clear) byte sequence packs into Fp limbs and
527        /// unpacks back to the original bytes, zero-padded on the right to a
528        /// multiple of 8. ASCII bytes keep each 8-byte chunk's `u64` strictly
529        /// below the Goldilocks modulus, so encoding never errors.
530        #[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        /// `hash_auth_message` is deterministic over arbitrary ASCII inputs.
550        #[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        /// Self-issued tokens always verify under the derived public key for
558        /// any non-zero `k` and any in-range deadline.
559        #[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    /// Layer 2 oracle: the closed-source signer's auth tokens must verify
592    /// under the same public key our `PrivateKey::sign` derives, against the
593    /// same `hash_auth_message` digest.
594    #[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        // The CSPRNG draw is reduced modulo the curve order, so every call
644        // returns a canonical scalar regardless of the raw byte values.
645        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        // With 320 bits of entropy a collision in three draws is unreachable
657        // in any realistic execution; this guards against a hard-coded
658        // constant or a misconfigured RNG.
659        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        // Mint a token for the credential and verify the embedded signature
671        // against the credential's public key. End-to-end check that the
672        // `build_auth_token_for` threads private_key, account_index, and api_key_index
673        // through the message and signature correctly.
674        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        // Sanity check that the message body is shaped `deadline:account:api_key`.
696        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}