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}