Skip to main content

atd_runtime/
cursor.rs

1//! Stateless HMAC-signed cursors for paginated tool results.
2//!
3//! SP-pagination-v1 §4.2 + §4.5. The reference cursor implementation is
4//! deliberately stateless: the server doesn't keep a cursor table — it
5//! HMAC-signs the payload and trusts the verified bytes on each
6//! `RunToolContinue`. Trade-off: 512-byte wire cap limits embedded
7//! state (~256B for `opaque_state` after fixed-field overhead).
8//!
9//! Adopters writing their own paginating tools can use [`CursorIssuer`]
10//! to issue + verify cursors without touching keys directly — call
11//! `ctx.cursor_issuer()` from inside `Tool::call_paginated` (Phase D wiring).
12//!
13//! ## Cursor wire shape
14//!
15//! ```text
16//! base64url( CBOR(CursorPayload) || HMAC-SHA256(key, CBOR(CursorPayload)) )
17//! ```
18//!
19//! - CBOR encoding is ~80B for the fixed fields, leaving ~250B for
20//!   `opaque_state` within the 512-byte cap.
21//! - HMAC tag is 32 bytes (SHA-256 output, constant-time verified).
22//! - base64url-no-pad keeps the cursor safe to ship inside JSON
23//!   `arguments.__cursor` (the HTTP / MCP-bridge surface, Phases F-G).
24//!
25//! ## Cursor scope
26//!
27//! Cursors are bound to `(tool_id, caller_id, args_fingerprint, server_session)`:
28//!
29//! - `tool_id` — server rejects `RunToolContinue` whose `tool_id` ≠ embedded.
30//! - `caller_id` — if the connection's identity changes (UDS Hello vs HTTP
31//!   bearer), the cursor is invalidated (mismatch returns `ERR_CURSOR_INVALID`).
32//! - `args_fingerprint` — SHA-256 of canonical-JSON-serialized original args;
33//!   continuing with mutated args is a protocol violation.
34//! - `server_session` — random nonce minted at issuer construction; server
35//!   restart → new nonce → all outstanding cursors invalidated (returns
36//!   `ERR_CURSOR_EXPIRED` so adopters can distinguish from forgery).
37
38use base64::Engine;
39use hmac::{Hmac, Mac};
40use serde::{Deserialize, Serialize};
41use sha2::Sha256;
42
43type HmacSha256 = Hmac<Sha256>;
44
45/// Maximum encoded cursor length on the wire. Bounded so cursors can
46/// safely ride inside HTTP headers, JSON-RPC `arguments.__cursor` fields,
47/// and audit log lines without truncation.
48pub const MAX_CURSOR_BYTES: usize = 512;
49
50/// Maximum opaque-state size inside a cursor payload. Operators who need
51/// more should store state server-side keyed by a 16-byte cursor ID and
52/// put just the ID in `opaque_state`.
53pub const MAX_OPAQUE_STATE_BYTES: usize = 256;
54
55/// Decoded cursor payload. Server-internal; clients never see the
56/// individual fields, only the base64url-encoded signed blob.
57#[derive(Serialize, Deserialize, Debug, Clone)]
58pub struct CursorPayload {
59    /// Canonical tool id the cursor is bound to. Mismatching tool_id on
60    /// `RunToolContinue` returns `ERR_CURSOR_INVALID`.
61    pub tool_id: String,
62    /// Connection caller identity at issuance time. `None` for anonymous
63    /// pre-Hello connections.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub caller_id: Option<String>,
66    /// SHA-256 of the canonical-JSON-serialized original `RunTool.args`.
67    /// Integrity check, not storage: tools that need to replay args put
68    /// a copy inside `opaque_state` (within the 256-byte budget).
69    pub args_fingerprint: [u8; 32],
70    /// 1-based page index. Useful for audit-trail correlation and
71    /// abuse detection (a client requesting page=10000 is sus).
72    pub page_index: u32,
73    /// UNIX timestamp (seconds) at issuance. Server checks `now - issued_at
74    /// <= ttl_seconds`.
75    pub issued_at_unix: u64,
76    /// Per-process random nonce from [`CursorIssuer::session_nonce`]. Server
77    /// restart → new nonce → old cursors expire.
78    pub server_session: [u8; 8],
79    /// Tool-defined per-cursor state. Capped at `MAX_OPAQUE_STATE_BYTES`.
80    /// Typical use: a database keyset cursor, an offset/limit pair, or
81    /// an encrypted continuation token from an upstream API.
82    #[serde(default, with = "serde_bytes")]
83    pub opaque_state: Vec<u8>,
84}
85
86/// Errors from cursor issue / verify operations.
87#[derive(thiserror::Error, Debug)]
88pub enum CursorError {
89    /// Cursor's `issued_at_unix` is older than `ttl_seconds`, or its
90    /// `server_session` doesn't match the current issuer (server restarted).
91    /// Maps to `ERR_CURSOR_EXPIRED` on the wire.
92    #[error("cursor expired")]
93    Expired,
94    /// HMAC verification failed AND the cursor's embedded `server_session`
95    /// matches the current issuer's nonce. This narrows the cause to real
96    /// forgery / tampering: an attacker constructed a body whose nonce
97    /// happened to match (or, with probability 2⁻⁶⁴, key rotation that
98    /// preserved the nonce). Maps to `ERR_CURSOR_INVALID` on the wire —
99    /// adopters should surface as a security signal.
100    ///
101    /// Server restart (random key AND random nonce both rotated) is the
102    /// expected lifecycle of single-instance deployments and is reported
103    /// as [`Self::Expired`] instead, so adopters can transparently drop
104    /// the persisted cursor and re-issue.
105    #[error("cursor signature invalid")]
106    InvalidSignature,
107    /// Malformed encoding (bad base64, bad CBOR). Maps to `ERR_CURSOR_INVALID`.
108    #[error("cursor format invalid: {0}")]
109    Format(String),
110    /// Encoded cursor exceeds `MAX_CURSOR_BYTES`. Returned only on `issue`
111    /// (verify checks the same cap before decoding).
112    #[error("cursor too large: {0} bytes (max 512)")]
113    TooLarge(usize),
114    /// `opaque_state` exceeds `MAX_OPAQUE_STATE_BYTES`. Caught at issue
115    /// time so a tool can't accidentally embed a 1MB blob.
116    #[error("opaque_state too large: {0} bytes (max 256)")]
117    OpaqueStateTooLarge(usize),
118}
119
120/// HMAC-SHA256 cursor issuer + verifier. One per server process; constructed
121/// at startup with `SharedServerConfig.cursor_signing_key`. Multi-instance
122/// deployments behind a load balancer can share a key via env
123/// (`ATD_CURSOR_SIGNING_KEY=base64...`); single-instance deployments use
124/// a fresh random key per startup (default).
125pub struct CursorIssuer {
126    key: [u8; 32],
127    session_nonce: [u8; 8],
128}
129
130impl CursorIssuer {
131    /// Build with an explicit signing key + a fresh-random session nonce.
132    /// The nonce changes on each construction so server restart invalidates
133    /// outstanding cursors even if the key is reused across restarts.
134    pub fn new(key: [u8; 32]) -> Self {
135        let mut nonce = [0u8; 8];
136        getrandom::getrandom(&mut nonce).expect("OS RNG");
137        Self {
138            key,
139            session_nonce: nonce,
140        }
141    }
142
143    /// The per-process random session nonce. Cursors carry this in their
144    /// payload; if the verifier sees a non-matching nonce, the cursor is
145    /// from a prior server process (or a different process entirely) —
146    /// treated as expired.
147    pub fn session_nonce(&self) -> [u8; 8] {
148        self.session_nonce
149    }
150
151    /// Sign + encode a [`CursorPayload`]. The encoded result is suitable for
152    /// stuffing into `Response::ToolResultResponse.next_cursor`.
153    pub fn issue(&self, payload: CursorPayload) -> Result<String, CursorError> {
154        if payload.opaque_state.len() > MAX_OPAQUE_STATE_BYTES {
155            return Err(CursorError::OpaqueStateTooLarge(payload.opaque_state.len()));
156        }
157        let mut body = Vec::with_capacity(256);
158        ciborium::into_writer(&payload, &mut body)
159            .map_err(|e| CursorError::Format(e.to_string()))?;
160        let mut mac =
161            HmacSha256::new_from_slice(&self.key).expect("HMAC accepts arbitrary key lengths");
162        mac.update(&body);
163        let tag = mac.finalize().into_bytes();
164        let mut combined = body;
165        combined.extend_from_slice(&tag);
166        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&combined);
167        if encoded.len() > MAX_CURSOR_BYTES {
168            return Err(CursorError::TooLarge(encoded.len()));
169        }
170        Ok(encoded)
171    }
172
173    /// Verify HMAC, decode CBOR, check TTL + session nonce. Returns the
174    /// decoded payload on success; the dispatch layer then checks
175    /// `tool_id` / `caller_id` / `args_fingerprint` against the
176    /// continuation request.
177    pub fn verify(&self, cursor: &str, ttl_seconds: u64) -> Result<CursorPayload, CursorError> {
178        if cursor.len() > MAX_CURSOR_BYTES {
179            return Err(CursorError::TooLarge(cursor.len()));
180        }
181        let combined = base64::engine::general_purpose::URL_SAFE_NO_PAD
182            .decode(cursor)
183            .map_err(|e| CursorError::Format(e.to_string()))?;
184        if combined.len() < 32 {
185            return Err(CursorError::Format("missing HMAC tag".into()));
186        }
187        let (body, tag) = combined.split_at(combined.len() - 32);
188        let mut mac =
189            HmacSha256::new_from_slice(&self.key).expect("HMAC accepts arbitrary key lengths");
190        mac.update(body);
191        // Constant-time tag comparison via the hmac crate.
192        if mac.verify_slice(tag).is_err() {
193            // HMAC verification failed. Two physical causes share this code
194            // path:
195            //   (a) Forgery / tampering — someone produced a cursor whose
196            //       body doesn't authenticate under our key.
197            //   (b) Key rotation — this process was restarted, the issuer's
198            //       random key changed, and an adopter is replaying a
199            //       cursor signed by a previous incarnation of the same
200            //       logical server. This is the **expected** lifecycle for
201            //       single-instance deployments where `ATD_CURSOR_SIGNING_KEY`
202            //       is not set.
203            //
204            // Adopters want to differentiate: (a) is a security signal that
205            // should be surfaced loudly, (b) is "drop the persisted cursor
206            // and re-issue from the initial RunTool". Probe-decode the body
207            // *unverified* to read its `server_session` nonce and split:
208            //
209            //   - nonce ≠ self.session_nonce → key + nonce both rotated at
210            //     startup → return `Expired` (maps to ERR_CURSOR_EXPIRED on
211            //     the wire); adopters know to drop and re-issue.
212            //   - nonce == self.session_nonce → either real tampering, or
213            //     the astronomically unlikely 2⁻⁶⁴ nonce collision after a
214            //     key rotation → return `InvalidSignature` (ERR_CURSOR_INVALID);
215            //     adopters surface as a security signal.
216            //
217            // The probe-decode is `ciborium::from_reader`; CBOR is panic-safe
218            // by design and a malformed body simply collapses to Format-style
219            // failure → fall through to `InvalidSignature` (we can't tell
220            // (a) from (b) in that case, so we're conservative and pick the
221            // security-signal variant).
222            if let Ok(probe) = ciborium::from_reader::<CursorPayload, _>(body)
223                && probe.server_session != self.session_nonce
224            {
225                return Err(CursorError::Expired);
226            }
227            return Err(CursorError::InvalidSignature);
228        }
229        let payload: CursorPayload =
230            ciborium::from_reader(body).map_err(|e| CursorError::Format(e.to_string()))?;
231        if payload.server_session != self.session_nonce {
232            return Err(CursorError::Expired);
233        }
234        let now = std::time::SystemTime::now()
235            .duration_since(std::time::UNIX_EPOCH)
236            .map(|d| d.as_secs())
237            .unwrap_or(0);
238        if now.saturating_sub(payload.issued_at_unix) > ttl_seconds {
239            return Err(CursorError::Expired);
240        }
241        Ok(payload)
242    }
243}
244
245/// Generate a fresh 32-byte cursor signing key from the OS RNG. Used by
246/// listener crates (`atd-server` / `atd-server-http`) at `Server::new`
247/// so they don't have to take a direct `getrandom` dep.
248pub fn random_signing_key() -> [u8; 32] {
249    let mut k = [0u8; 32];
250    getrandom::getrandom(&mut k).expect("OS RNG");
251    k
252}
253
254/// SP-pagination-v1 §4.2 — pick a cursor signing key by precedence:
255///
256/// 1. `ATD_CURSOR_SIGNING_KEY` env (base64-url or standard base64 of
257///    exactly 32 bytes) — for multi-instance deployments behind a load
258///    balancer where every instance must verify cursors any sibling
259///    issued. Operators are responsible for keeping the value secret;
260///    the env is read once at `Server::new` and never logged.
261/// 2. Random 32-byte key from `random_signing_key()` — single-instance
262///    default. Server restart → new key → outstanding cursors fail
263///    with `ERR_CURSOR_EXPIRED`.
264///
265/// Invalid env values (non-base64, wrong length) fall back to random with
266/// a warning on stderr. This is deliberate fail-closed: a misconfigured
267/// shared deployment becomes single-instance rather than insecure.
268pub fn signing_key_from_env_or_random() -> [u8; 32] {
269    if let Ok(value) = std::env::var("ATD_CURSOR_SIGNING_KEY") {
270        let trimmed = value.trim();
271        if !trimmed.is_empty() {
272            let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
273                .decode(trimmed)
274                .or_else(|_| base64::engine::general_purpose::STANDARD.decode(trimmed));
275            match decoded {
276                Ok(bytes) if bytes.len() == 32 => {
277                    let mut k = [0u8; 32];
278                    k.copy_from_slice(&bytes);
279                    return k;
280                }
281                Ok(bytes) => {
282                    eprintln!(
283                        "atd-runtime: ATD_CURSOR_SIGNING_KEY decoded to {} bytes; \
284                         expected 32. Falling back to random per-process key.",
285                        bytes.len()
286                    );
287                }
288                Err(e) => {
289                    eprintln!(
290                        "atd-runtime: ATD_CURSOR_SIGNING_KEY base64 decode failed: {e}. \
291                         Falling back to random per-process key."
292                    );
293                }
294            }
295        }
296    }
297    random_signing_key()
298}
299
300/// Compute the canonical `args_fingerprint` for a `RunTool.args` value.
301/// Used at issue time (server) and could be used at verify time (server)
302/// if the dispatch layer wants to check that a continuation's args
303/// match the original. The reference dispatch (Phase D) passes
304/// `serde_json::Value::Null` on continuations and binds args via the
305/// embedded opaque_state, so this is mainly an integrity tool.
306pub fn args_fingerprint(args: &serde_json::Value) -> [u8; 32] {
307    use sha2::Digest;
308    // Serialize via the standard serde_json writer — non-canonical, but
309    // round-trip-stable for the same in-memory value, which is what we need.
310    let bytes = serde_json::to_vec(args).unwrap_or_default();
311    let mut hasher = Sha256::new();
312    hasher.update(&bytes);
313    hasher.finalize().into()
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use serde_json::json;
320    use std::time::{Duration, SystemTime, UNIX_EPOCH};
321
322    fn now() -> u64 {
323        SystemTime::now()
324            .duration_since(UNIX_EPOCH)
325            .unwrap()
326            .as_secs()
327    }
328
329    fn fresh_issuer() -> CursorIssuer {
330        let mut key = [0u8; 32];
331        getrandom::getrandom(&mut key).unwrap();
332        CursorIssuer::new(key)
333    }
334
335    fn mk_payload(issuer: &CursorIssuer, page: u32) -> CursorPayload {
336        CursorPayload {
337            tool_id: "celia:fhir.list_observations".into(),
338            caller_id: Some("test-caller".into()),
339            args_fingerprint: args_fingerprint(&json!({"patient": "p1"})),
340            page_index: page,
341            issued_at_unix: now(),
342            server_session: issuer.session_nonce(),
343            opaque_state: vec![],
344        }
345    }
346
347    #[test]
348    fn issue_round_trips() {
349        let issuer = fresh_issuer();
350        let payload = mk_payload(&issuer, 2);
351        let cursor = issuer.issue(payload.clone()).expect("issue");
352        let back = issuer.verify(&cursor, 300).expect("verify");
353        assert_eq!(back.tool_id, payload.tool_id);
354        assert_eq!(back.page_index, 2);
355        assert_eq!(back.args_fingerprint, payload.args_fingerprint);
356    }
357
358    #[test]
359    fn verify_rejects_tampered_signature() {
360        let issuer = fresh_issuer();
361        let cursor = issuer.issue(mk_payload(&issuer, 1)).unwrap();
362        // Flip a base64 character ~16 chars from the end so we land inside
363        // the HMAC tag region without breaking base64 framing (the last
364        // few chars often have alignment-sensitive padding bits).
365        let mut bytes: Vec<u8> = cursor.bytes().collect();
366        let target = bytes.len() - 16;
367        bytes[target] = if bytes[target] == b'A' { b'B' } else { b'A' };
368        let tampered = String::from_utf8(bytes).unwrap();
369        match issuer.verify(&tampered, 300) {
370            Err(CursorError::InvalidSignature) => {}
371            other => panic!("expected InvalidSignature, got {other:?}"),
372        }
373    }
374
375    #[test]
376    fn verify_rejects_after_ttl() {
377        let issuer = fresh_issuer();
378        let mut payload = mk_payload(&issuer, 1);
379        payload.issued_at_unix = now().saturating_sub(400); // 400s ago
380        let cursor = issuer.issue(payload).unwrap();
381        match issuer.verify(&cursor, 300) {
382            Err(CursorError::Expired) => {}
383            other => panic!("expected Expired, got {other:?}"),
384        }
385    }
386
387    #[test]
388    fn verify_rejects_wrong_session_nonce() {
389        // Two issuers sharing a key but different nonces — simulates server restart
390        // with a persistent key.
391        let key = {
392            let mut k = [0u8; 32];
393            getrandom::getrandom(&mut k).unwrap();
394            k
395        };
396        let issuer_a = CursorIssuer::new(key);
397        let issuer_b = CursorIssuer::new(key);
398        assert_ne!(issuer_a.session_nonce(), issuer_b.session_nonce());
399        let cursor = issuer_a.issue(mk_payload(&issuer_a, 1)).unwrap();
400        match issuer_b.verify(&cursor, 300) {
401            Err(CursorError::Expired) => {}
402            other => panic!("expected Expired (cross-session), got {other:?}"),
403        }
404    }
405
406    #[test]
407    fn verify_treats_server_restart_as_expired_not_forgery() {
408        // The real-world server-restart shape: both `key` and
409        // `session_nonce` are freshly minted by `random_signing_key()` /
410        // OS RNG, so they differ from the previous incarnation. Adopters
411        // replaying an old cursor must see `Expired` (1020), not
412        // `InvalidSignature` (1021), so they know to drop the persisted
413        // cursor and re-issue the original `RunTool`.
414        let issuer_a = fresh_issuer();
415        let issuer_b = fresh_issuer(); // different random key AND nonce
416        let cursor = issuer_a.issue(mk_payload(&issuer_a, 1)).unwrap();
417        match issuer_b.verify(&cursor, 300) {
418            Err(CursorError::Expired) => {}
419            other => panic!("expected Expired (server restart), got {other:?}"),
420        }
421    }
422
423    #[test]
424    fn verify_rejects_real_forgery_with_invalid_signature() {
425        // Attacker holds a legitimate cursor and tampers with the body
426        // (flipping the page_index byte by re-encoding). The nonce
427        // embedded in the forged body necessarily matches our current
428        // session (since we re-encode under our own nonce); HMAC must
429        // still fail because the attacker doesn't have our key. The
430        // probe-decode path sees nonce-match and correctly classifies
431        // this as `InvalidSignature` (1021) — the security-signal code.
432        let issuer = fresh_issuer();
433        let original = issuer.issue(mk_payload(&issuer, 1)).unwrap();
434
435        // Synthesize a body with a different page_index but the *same*
436        // (current) nonce, then concatenate someone else's HMAC tag
437        // (use the original tag — guaranteed not to match the tampered
438        // body).
439        let combined = base64::engine::general_purpose::URL_SAFE_NO_PAD
440            .decode(&original)
441            .unwrap();
442        let (_orig_body, orig_tag) = combined.split_at(combined.len() - 32);
443
444        let tampered_payload = CursorPayload {
445            tool_id: "test:tool".into(),
446            caller_id: Some("test-caller".into()),
447            args_fingerprint: [0u8; 32],
448            page_index: 999, // ← attacker's chosen value
449            issued_at_unix: now(),
450            server_session: issuer.session_nonce(), // ← current nonce
451            opaque_state: vec![],
452        };
453        let mut tampered_body = Vec::new();
454        ciborium::into_writer(&tampered_payload, &mut tampered_body).unwrap();
455
456        let mut forged = tampered_body;
457        forged.extend_from_slice(orig_tag);
458        let forged_cursor = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&forged);
459
460        match issuer.verify(&forged_cursor, 300) {
461            Err(CursorError::InvalidSignature) => {}
462            other => panic!("expected InvalidSignature (real forgery), got {other:?}"),
463        }
464    }
465
466    #[test]
467    fn verify_unparseable_body_after_hmac_fail_is_invalid_signature() {
468        // Garbage CBOR + garbage HMAC tag (combined long enough to pass
469        // the length check). The probe-decode fails; we fall through to
470        // `InvalidSignature` rather than guessing — conservative posture.
471        let issuer = fresh_issuer();
472        let garbage = vec![0xffu8; 64]; // 32B body + 32B tag, all 0xff
473        let garbage_cursor = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&garbage);
474        match issuer.verify(&garbage_cursor, 300) {
475            Err(CursorError::InvalidSignature) => {}
476            other => panic!("expected InvalidSignature for unparseable, got {other:?}"),
477        }
478    }
479
480    #[test]
481    fn issue_rejects_oversized_opaque_state() {
482        let issuer = fresh_issuer();
483        let mut payload = mk_payload(&issuer, 1);
484        payload.opaque_state = vec![0u8; MAX_OPAQUE_STATE_BYTES + 1];
485        match issuer.issue(payload) {
486            Err(CursorError::OpaqueStateTooLarge(n)) => {
487                assert_eq!(n, MAX_OPAQUE_STATE_BYTES + 1)
488            }
489            other => panic!("expected OpaqueStateTooLarge, got {other:?}"),
490        }
491    }
492
493    #[test]
494    fn issue_rejects_oversized_payload_via_long_tool_id() {
495        let issuer = fresh_issuer();
496        // Pad opaque_state to its max + a long tool_id to push the
497        // encoded blob past 512 bytes.
498        let mut payload = mk_payload(&issuer, 1);
499        payload.tool_id = "x".repeat(400);
500        payload.opaque_state = vec![0u8; MAX_OPAQUE_STATE_BYTES];
501        match issuer.issue(payload) {
502            Err(CursorError::TooLarge(n)) => {
503                assert!(n > MAX_CURSOR_BYTES, "got {n}");
504            }
505            other => panic!("expected TooLarge, got {other:?}"),
506        }
507    }
508
509    #[test]
510    fn verify_rejects_malformed_base64() {
511        let issuer = fresh_issuer();
512        match issuer.verify("not!base64!", 300) {
513            Err(CursorError::Format(_)) => {}
514            other => panic!("expected Format error, got {other:?}"),
515        }
516    }
517
518    #[test]
519    fn verify_rejects_too_short_combined() {
520        let issuer = fresh_issuer();
521        // 8 bytes of valid base64 → 6 raw bytes → less than 32-byte HMAC tag.
522        let cursor = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"abcdef");
523        match issuer.verify(&cursor, 300) {
524            Err(CursorError::Format(_)) => {}
525            other => panic!("expected Format (missing HMAC tag), got {other:?}"),
526        }
527    }
528
529    #[test]
530    fn args_fingerprint_stable_for_same_value() {
531        let a = args_fingerprint(&json!({"x": 1, "y": [2, 3]}));
532        let b = args_fingerprint(&json!({"x": 1, "y": [2, 3]}));
533        assert_eq!(a, b);
534    }
535
536    #[test]
537    fn args_fingerprint_differs_for_different_value() {
538        let a = args_fingerprint(&json!({"x": 1}));
539        let b = args_fingerprint(&json!({"x": 2}));
540        assert_ne!(a, b);
541    }
542
543    #[test]
544    fn cursor_under_cap_for_typical_payload() {
545        let issuer = fresh_issuer();
546        let payload = mk_payload(&issuer, 1);
547        let cursor = issuer.issue(payload).unwrap();
548        // Typical payload (no opaque_state, ~30-char tool_id, ~11-char
549        // caller_id) measured at ~334 base64 chars in practice. The hard
550        // cap is MAX_CURSOR_BYTES (512); anything well under that is fine.
551        assert!(
552            cursor.len() < MAX_CURSOR_BYTES,
553            "typical cursor over cap: {} > {}",
554            cursor.len(),
555            MAX_CURSOR_BYTES
556        );
557    }
558
559    #[test]
560    fn opaque_state_round_trips() {
561        let issuer = fresh_issuer();
562        let mut payload = mk_payload(&issuer, 1);
563        payload.opaque_state = b"keyset:last_id=42,page=3".to_vec();
564        let cursor = issuer.issue(payload.clone()).unwrap();
565        let back = issuer.verify(&cursor, 300).unwrap();
566        assert_eq!(back.opaque_state, payload.opaque_state);
567    }
568
569    /// Env-key wiring must be serialised across tests reading the same
570    /// process-global var; we use a static mutex instead of pulling in
571    /// `serial_test` so the crate stays dep-tight.
572    fn signing_key_env_lock() -> std::sync::MutexGuard<'static, ()> {
573        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
574        LOCK.get_or_init(|| std::sync::Mutex::new(()))
575            .lock()
576            .unwrap_or_else(|p| p.into_inner())
577    }
578
579    #[test]
580    fn signing_key_from_env_reads_base64url_no_pad() {
581        let _g = signing_key_env_lock();
582        let want = [7u8; 32];
583        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(want);
584        unsafe { std::env::set_var("ATD_CURSOR_SIGNING_KEY", &encoded) };
585        let got = signing_key_from_env_or_random();
586        unsafe { std::env::remove_var("ATD_CURSOR_SIGNING_KEY") };
587        assert_eq!(got, want, "env key must round-trip verbatim");
588    }
589
590    #[test]
591    fn signing_key_from_env_falls_back_on_wrong_length() {
592        let _g = signing_key_env_lock();
593        // 16-byte key, base64-encoded — wrong length, must fall back.
594        let bad = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1u8; 16]);
595        unsafe { std::env::set_var("ATD_CURSOR_SIGNING_KEY", &bad) };
596        let got = signing_key_from_env_or_random();
597        unsafe { std::env::remove_var("ATD_CURSOR_SIGNING_KEY") };
598        // Random fallback never equals all-1s.
599        assert_ne!(got, [1u8; 32]);
600    }
601
602    #[test]
603    fn signing_key_from_env_falls_back_on_garbage() {
604        let _g = signing_key_env_lock();
605        unsafe { std::env::set_var("ATD_CURSOR_SIGNING_KEY", "not-base64!!") };
606        let got = signing_key_from_env_or_random();
607        unsafe { std::env::remove_var("ATD_CURSOR_SIGNING_KEY") };
608        // Just assert it returned something (random); shape-validity via
609        // construction.
610        let _: [u8; 32] = got;
611    }
612
613    #[test]
614    fn signing_key_from_env_falls_back_when_unset() {
615        let _g = signing_key_env_lock();
616        unsafe { std::env::remove_var("ATD_CURSOR_SIGNING_KEY") };
617        let a = signing_key_from_env_or_random();
618        let b = signing_key_from_env_or_random();
619        assert_ne!(a, b, "random fallback should yield distinct keys");
620    }
621
622    /// Wait one second so `issued_at_unix` advances; verifies TTL semantics
623    /// without flakiness from clock granularity.
624    #[test]
625    fn ttl_zero_rejects_freshly_issued() {
626        let issuer = fresh_issuer();
627        let mut payload = mk_payload(&issuer, 1);
628        payload.issued_at_unix = now().saturating_sub(1); // 1s ago
629        let cursor = issuer.issue(payload).unwrap();
630        // TTL = 0 means "anything older than 0s is expired"
631        std::thread::sleep(Duration::from_millis(10));
632        match issuer.verify(&cursor, 0) {
633            Err(CursorError::Expired) => {}
634            other => panic!("expected Expired with ttl=0, got {other:?}"),
635        }
636    }
637}