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 the underlying [`crate::common::credential::Credential::private_key`]
113/// failure if the secret cannot be decoded, or any [`build_auth_token`]
114/// failure (clock-before-epoch or, hypothetically, a breach caused by its own
115/// deadline validation).
116pub 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/// Draws a fresh canonical [`Scalar`] from the thread-local CSPRNG suitable
140/// for the per-signature `k` nonce.
141///
142/// The Schnorr binding requires `k` to be drawn from a cryptographic RNG and
143/// used at most once per signature; see [`PrivateKey::sign`] for the full
144/// contract. The 40-byte draw is reduced modulo the curve order, so the
145/// returned scalar is always canonical.
146#[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
153/// Build a Lighter auth token using the system clock as the `now` reference.
154///
155/// Validates the deadline against [`LIGHTER_AUTH_TOKEN_MAX_TTL`]. See
156/// [`build_auth_token_at`] for an injectable-`now` variant suitable for tests.
157///
158/// # Errors
159///
160/// Returns [`AuthTokenError::DeadlineNotInFuture`] if `deadline_unix_secs <=
161/// now`, [`AuthTokenError::TtlTooLarge`] if it exceeds the venue cap, or
162/// [`AuthTokenError::ClockBeforeEpoch`] if the system clock predates the
163/// UNIX epoch.
164pub 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
186/// Variant of [`build_auth_token`] accepting an explicit `now_unix_secs`.
187///
188/// Pure of `SystemTime`, so callers can drive the validation deterministically
189/// from a wall-clock provider or a test fixture. Same error semantics as
190/// [`build_auth_token`].
191///
192/// # Errors
193///
194/// See [`build_auth_token`].
195pub 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
224/// Sign the auth-token message without TTL validation.
225///
226/// Public so tests and oracle round-trips can produce tokens whose deadline
227/// would otherwise trip the venue cap. Production callers should use
228/// [`build_auth_token`] or [`build_auth_token_at`].
229///
230/// # Errors
231///
232/// Returns [`AuthTokenError::MessageEncoding`] if the rendered message contains
233/// an 8-byte chunk that does not decode as a canonical Goldilocks element. The
234/// auth-token format keeps every byte ASCII, so this case is unreachable for
235/// the production callers.
236pub 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/// ASCII auth-token message: `"{deadline}:{account}:{api_key}"`.
249///
250/// Public for the rare caller that needs to recompute the signed preimage
251/// (e.g., to verify a token against a known public key). The message format
252/// matches the Go reference verbatim.
253#[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
262/// Hash the auth-token ASCII message to its 40-byte `Fp5` digest.
263///
264/// Splits the bytes into 8-byte little-endian Goldilocks limbs (zero-padding
265/// the trailing chunk) and runs [`hash_to_quintic_extension`] over the limbs.
266/// Returns the canonical 40-byte little-endian encoding the Schnorr binding
267/// signs over.
268///
269/// # Errors
270///
271/// Returns [`AuthTokenError::MessageEncoding`] if any 8-byte chunk decodes to
272/// a non-canonical Goldilocks element. The auth-token format is ASCII
273/// (`'0'..='9'` and `':'`), so every byte sits in `0..=0x3A` and the case is
274/// unreachable for tokens emitted by [`auth_token_message`]; the error path
275/// exists for callers that pass arbitrary preimages.
276pub 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    // Lowercase, no `0x` prefix: matches `ethCommon.Bytes2Hex` in the Go
310    // reference, which is what the venue's REST/WS handshake expects.
311    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        // Same seed bytes as the tx-oracle fixture; keeps the auth-token
332        // tests self-contained without piggybacking on the tx fixture file.
333        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        // Smoke-test the system-clock variant: seed a deadline 600s ahead of
357        // wall-clock now and verify the token's structural shape. The signing
358        // pipeline is exercised by the *_at variant; this test just gates the
359        // SystemTime plumbing and Result threading.
360        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        // "abc" -> single limb [0x61, 0x62, 0x63, 0, 0, 0, 0, 0].
494        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        // 9 bytes spill into two limbs: first full, second carries one byte.
500        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        // u64::MAX > MODULUS so the 8-byte chunk is non-canonical.
508        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        /// Any ASCII (high-bit-clear) byte sequence packs into Fp limbs and
532        /// unpacks back to the original bytes, zero-padded on the right to a
533        /// multiple of 8. ASCII bytes keep each 8-byte chunk's `u64` strictly
534        /// below the Goldilocks modulus, so encoding never errors.
535        #[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        /// `hash_auth_message` is deterministic over arbitrary ASCII inputs.
555        #[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        /// Self-issued tokens always verify under the derived public key for
563        /// any non-zero `k` and any in-range deadline.
564        #[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    /// Layer 2 oracle: the closed-source signer's auth tokens must verify
597    /// under the same public key our `PrivateKey::sign` derives, against the
598    /// same `hash_auth_message` digest.
599    #[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        // The CSPRNG draw is reduced modulo the curve order, so every call
649        // returns a canonical scalar regardless of the raw byte values.
650        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        // With 320 bits of entropy a collision in three draws is unreachable
662        // in any realistic execution; this guards against a hard-coded
663        // constant or a misconfigured RNG.
664        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        // Mint a token for the credential and verify the embedded signature
676        // against the credential's public key. End-to-end check that the
677        // `build_auth_token_for` threads private_key, account_index, and api_key_index
678        // through the message and signature correctly.
679        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        // Sanity check that the message body is shaped `deadline:account:api_key`.
701        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}