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 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/// Errors raised by [`build_auth_token`].
59#[derive(Debug, Error, PartialEq, Eq)]
60pub enum AuthTokenError {
61    /// The supplied deadline is at or before `now`.
62    #[error("auth-token deadline {deadline} is not in the future of now {now}")]
63    DeadlineNotInFuture {
64        /// Caller-supplied deadline (UNIX seconds).
65        deadline: i64,
66        /// Reference `now` (UNIX seconds).
67        now: i64,
68    },
69    /// The supplied deadline exceeds the venue's maximum TTL.
70    #[error("auth-token deadline {deadline} exceeds max TTL {max_ttl_secs}s from now {now}")]
71    TtlTooLarge {
72        /// Caller-supplied deadline (UNIX seconds).
73        deadline: i64,
74        /// Reference `now` (UNIX seconds).
75        now: i64,
76        /// Configured maximum TTL in seconds.
77        max_ttl_secs: i64,
78    },
79    /// The system clock is before the UNIX epoch.
80    #[error("system clock is before UNIX epoch")]
81    ClockBeforeEpoch,
82    /// An 8-byte chunk of the message decoded to a non-canonical Goldilocks
83    /// element (`>= p`).
84    ///
85    /// Cannot occur for the auth-token format (`"{deadline}:{account}:{key}"`)
86    /// since every byte is ASCII; surfaced as a typed error rather than a
87    /// panic so callers of [`hash_auth_message`] with arbitrary input get a
88    /// recoverable failure.
89    #[error("non-canonical Goldilocks limb at byte offset {offset}")]
90    MessageEncoding {
91        /// Byte offset of the offending 8-byte chunk in the input.
92        offset: usize,
93    },
94}
95
96/// Default deadline applied by [`build_auth_token_for`] when the caller does
97/// not supply one: 7 hours from the current wall clock. Sits inside the
98/// venue's [`LIGHTER_AUTH_TOKEN_MAX_TTL`] (8 hours) with an hour of head room
99/// so a long-running session can pre-fetch and rotate before expiry.
100pub const DEFAULT_AUTH_TOKEN_TTL_SECS: i64 = 7 * 60 * 60;
101
102/// Mint an auth token from a [`crate::common::credential::Credential`] using
103/// the default 7-hour TTL and a fresh CSPRNG nonce.
104///
105/// The token format matches the Go reference's `ConstructAuthToken`. The
106/// returned string is the value the WebSocket subscribe handshake sends in
107/// the `auth` field of an `account_*` channel subscription.
108///
109/// # Errors
110///
111/// Returns the underlying [`crate::common::credential::Credential::private_key`]
112/// failure if the secret cannot be decoded, or any [`build_auth_token`]
113/// failure (clock-before-epoch or, hypothetically, a deadline-validation
114/// breach the helper itself sets).
115pub 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/// 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 [`AuthTokenError::DeadlineNotInFuture`] if `deadline_unix_secs <=
160/// now`, [`AuthTokenError::TtlTooLarge`] if it exceeds the venue cap, or
161/// [`AuthTokenError::ClockBeforeEpoch`] if the system clock predates the
162/// UNIX epoch.
163pub 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
185/// Variant of [`build_auth_token`] accepting an explicit `now_unix_secs`.
186///
187/// Pure of `SystemTime`, so callers can drive the validation deterministically
188/// from a wall-clock provider or a test fixture. Same error semantics as
189/// [`build_auth_token`].
190///
191/// # Errors
192///
193/// See [`build_auth_token`].
194pub 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
223/// Sign the auth-token message without TTL validation.
224///
225/// Public so tests and oracle round-trips can produce tokens whose deadline
226/// would otherwise trip the venue cap. Production callers should use
227/// [`build_auth_token`] or [`build_auth_token_at`].
228///
229/// # Errors
230///
231/// Returns [`AuthTokenError::MessageEncoding`] if the rendered message contains
232/// an 8-byte chunk that does not decode as a canonical Goldilocks element. The
233/// auth-token format keeps every byte ASCII, so this case is unreachable for
234/// the production callers.
235pub 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/// ASCII auth-token message: `"{deadline}:{account}:{api_key}"`.
248///
249/// Public for the rare caller that needs to recompute the signed preimage
250/// (e.g., to verify a token against a known public key). The message format
251/// matches the Go reference verbatim.
252#[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
261/// Hash the auth-token ASCII message to its 40-byte `Fp5` digest.
262///
263/// Splits the bytes into 8-byte little-endian Goldilocks limbs (zero-padding
264/// the trailing chunk) and runs [`hash_to_quintic_extension`] over the limbs.
265/// Returns the canonical 40-byte little-endian encoding the Schnorr binding
266/// signs over.
267///
268/// # Errors
269///
270/// Returns [`AuthTokenError::MessageEncoding`] if any 8-byte chunk decodes to
271/// a non-canonical Goldilocks element. The auth-token format is ASCII
272/// (`'0'..='9'` and `':'`), so every byte sits in `0..=0x3A` and the case is
273/// unreachable for tokens emitted by [`auth_token_message`]; the error path
274/// exists for callers that pass arbitrary preimages.
275pub 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    // Lowercase, no `0x` prefix: matches `ethCommon.Bytes2Hex` in the Go
309    // reference, which is what the venue's REST/WS handshake expects.
310    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        // Same seed bytes as the tx-oracle fixture; keeps the auth-token
331        // tests self-contained without piggybacking on the tx fixture file.
332        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        // Smoke-test the system-clock variant: seed a deadline 600s ahead of
356        // wall-clock now and verify the token's structural shape. The signing
357        // pipeline is exercised by the *_at variant; this test just gates the
358        // SystemTime plumbing and Result threading.
359        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        // "abc" -> single limb [0x61, 0x62, 0x63, 0, 0, 0, 0, 0].
490        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        // 9 bytes spill into two limbs: first full, second carries one byte.
496        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        // u64::MAX > MODULUS so the 8-byte chunk is non-canonical.
504        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        /// Any ASCII (high-bit-clear) byte sequence packs into Fp limbs and
528        /// unpacks back to the original bytes, zero-padded on the right to a
529        /// multiple of 8. ASCII bytes keep each 8-byte chunk's `u64` strictly
530        /// below the Goldilocks modulus, so encoding never errors.
531        #[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        /// `hash_auth_message` is deterministic over arbitrary ASCII inputs.
551        #[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        /// Self-issued tokens always verify under the derived public key for
559        /// any non-zero `k` and any in-range deadline.
560        #[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    /// Layer 2 oracle: the closed-source signer's auth tokens must verify
593    /// under the same public key our `PrivateKey::sign` derives, against the
594    /// same `hash_auth_message` digest.
595    #[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        // The CSPRNG draw is reduced modulo the curve order, so every call
645        // returns a canonical scalar regardless of the raw byte values.
646        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        // With 320 bits of entropy a collision in three draws is unreachable
658        // in any realistic execution; this guards against a hard-coded
659        // constant or a misconfigured RNG.
660        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        // Mint a token for the credential and verify the embedded signature
672        // against the credential's public key. End-to-end check that the
673        // helper threads private_key, account_index, and api_key_index
674        // through the message and signature correctly.
675        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        // 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}