seshcookie 0.1.0

Stateless, encrypted, type-safe session cookies for Rust web applications.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
//! Cookie-value codec composing envelope + AEAD + base64url-no-pad.
//!
//! pattern: Functional Core
//!
//! `encode_cookie` seals a layer-1 plaintext and base64-encodes the result;
//! `decode_cookie` reverses it and tries the ordered key list until one
//! authenticates. Both are used crate-internally by [`crate::SessionLayer`]'s
//! request and response paths.
//!
//! This module is the single composition unit the [`crate::SessionLayer`]'s
//! `SessionService` calls on the request and response paths. It is intentionally
//! thin — three steps in each direction with no logic of its own — so the surface
//! area downstream code has to test or mock is minimal.
//!
//! ## Encode pipeline
//!
//! 1. `envelope::encode_envelope` builds the layer-1 plaintext
//!    (`format_version || issued_at || payload_json`).
//! 2. `aead::seal` encrypts that plaintext under the supplied derived key
//!    with a fresh random nonce, producing `nonce || ciphertext || tag`.
//! 3. URL-safe base64 with no padding turns the binary blob into an ASCII
//!    cookie value safe for `Set-Cookie` headers.
//!
//! ## Decode pipeline
//!
//! Each step is fallible; any failure short-circuits to `None` (silent
//! fallback), per the project rule that cookie-level problems must never
//! become HTTP-level errors. The returned `Option<(SystemTime, Vec<u8>,
//! usize)>` carries the issued-at time, the layer-2 payload bytes for the
//! caller to JSON-deserialize, and the rotation index Phase 3 uses to decide
//! whether to auto-migrate the cookie to the primary key.

use std::time::SystemTime;

use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine as _};
use ring::rand::SecureRandom;

use crate::aead::{self, DerivedKey};
use crate::envelope;

/// Encode a typed payload to a cookie value string.
///
/// The output is URL-safe base64 (no padding) of `nonce(12) || ciphertext ||
/// tag(16)`, where the inner plaintext is the layer-1 envelope:
/// `format_version(1) || issued_at(i64 LE seconds, 8) || payload_json`.
///
/// `payload_json` is supplied as bytes — the caller is responsible for
/// `serde_json::to_vec`-ing the typed payload. This keeps `codec` agnostic of
/// the payload type and avoids re-encoding inside this layer.
///
/// The Tower service in [`crate::layer`] does not call this helper directly:
/// because the response-path emission flow already has the layer-1 plaintext
/// in hand from [`crate::state::should_rewrite`], it composes `aead::seal` +
/// base64 inline rather than re-building the envelope. This helper remains
/// part of the codec surface for tests and for any future caller that needs
/// the full encode pipeline as one call.
#[allow(dead_code)] // Used by codec tests; the layer composes seal + base64 inline.
pub(crate) fn encode_cookie(
    key: &DerivedKey,
    rng: &dyn SecureRandom,
    issued_at: SystemTime,
    payload_json: &[u8],
) -> String {
    let layer1 = envelope::encode_envelope(issued_at, payload_json);
    let sealed = aead::seal(key, rng, &layer1);
    BASE64_URL_SAFE_NO_PAD.encode(&sealed)
}

/// Decode a cookie value into `(issued_at, payload_json, decrypt_key_index)`.
///
/// Returns `None` on every cookie-level failure mode — the design's silent-
/// fallback rule means callers up the stack treat a malformed, forged,
/// expired-shape, or unrecognized-version cookie identically to one that was
/// never sent. Specific failure modes (each verified by Phase 1 tests):
///
/// - Base64 decode failure (seshcookie-rs.AC6.1).
/// - Sealed payload shorter than 28 bytes — no nonce/tag space
///   (seshcookie-rs.AC6.2).
/// - AEAD authentication failure under every supplied key — wrong key, forged
///   ciphertext, tag tamper, etc. (seshcookie-rs.AC4.5, seshcookie-rs.AC6.3).
/// - Envelope decode failure: plaintext too short, or `format_version` byte
///   does not match the supported version (seshcookie-rs.AC6.4).
///
/// The returned `payload_json` is the raw layer-2 bytes; callers are
/// responsible for JSON-deserializing into their typed session payload. JSON
/// deserialization failures (seshcookie-rs.AC6.5) are surfaced one layer up,
/// because the bytes themselves did decrypt and are syntactically valid
/// cookie content.
pub(crate) fn decode_cookie(
    keys: &[DerivedKey],
    cookie_value: &str,
) -> Option<(SystemTime, Vec<u8>, usize)> {
    let sealed = BASE64_URL_SAFE_NO_PAD.decode(cookie_value).ok()?;
    let (layer1, key_index) = aead::try_decrypt(keys, &sealed)?;
    let (issued_at, payload_json) = envelope::decode_envelope(&layer1).ok()?;
    Some((issued_at, payload_json, key_index))
}

#[cfg(test)]
mod tests {
    use super::*;

    use std::time::Duration;

    use proptest::prelude::*;
    use ring::rand::SystemRandom;
    use serde::{Deserialize, Serialize};

    /// Inner shape exercising nested-struct round-trip (seshcookie-rs.AC1.2).
    #[derive(Clone, PartialEq, Debug, Serialize, Deserialize)]
    struct Inner {
        id: u64,
        tag: String,
    }

    /// Enum payload exercising variant round-trip (seshcookie-rs.AC1.2).
    #[derive(Clone, PartialEq, Debug, Serialize, Deserialize)]
    enum Role {
        Admin,
        Member(u32),
        Guest,
    }

    /// Production-shaped payload used across the codec tests. Combines the
    /// constructs the design plan requires Phase 1 to support: primitive
    /// fields, optional fields, vector-of-enum, nested struct.
    #[derive(Clone, PartialEq, Debug, Serialize, Deserialize)]
    struct TestPayload {
        user_id: u64,
        email: Option<String>,
        roles: Vec<Role>,
        nested: Option<Inner>,
    }

    /// IKM fixtures used by the rotation tests. Distinct byte patterns ensure
    /// HKDF-SHA256 expansion produces unrelated keys.
    const IKM_PRIMARY: &[u8; 32] = b"primary-ikm-fixed-bytes-32-len!!";
    const IKM_OTHER_1: &[u8; 32] = b"o1-fallback-ikm-fixed-bytes-32!!";
    const IKM_OTHER_2: &[u8; 32] = b"o2-fallback-ikm-fixed-bytes-32!!";
    const IKM_UNKNOWN: &[u8; 32] = b"x-unknown-ikm-not-in-keylist-32!";

    fn key_from(ikm: &[u8]) -> DerivedKey {
        DerivedKey::derive(ikm)
    }

    /// Representative `issued_at` used by the deterministic tests. The exact
    /// value is unimportant; what matters is that decode returns the same
    /// `SystemTime` byte-for-byte.
    fn fixed_issued_at() -> SystemTime {
        SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)
    }

    /// Build the canonical `TestPayload` used by AC1.1 / AC1.2 tests.
    fn sample_payload() -> TestPayload {
        TestPayload {
            user_id: 42,
            email: Some("a@b".into()),
            roles: vec![Role::Admin, Role::Member(7), Role::Guest],
            nested: Some(Inner {
                id: 1,
                tag: "x".into(),
            }),
        }
    }

    // --- AC1.1 + AC4.1: end-to-end round-trip via primary key ---------------------

    /// seshcookie-rs.AC1.1 (success) and seshcookie-rs.AC4.1 (rotation index 0):
    /// a payload encoded under a single-key list decrypts via that same list and
    /// returns `decrypt_key_index = 0`.
    #[test]
    fn round_trip_through_single_key_returns_index_zero_ac1_1_ac4_1() {
        let rng = SystemRandom::new();
        let key = key_from(IKM_PRIMARY);
        let payload = sample_payload();
        let payload_json = serde_json::to_vec(&payload).expect("payload serializes");
        let issued_at = fixed_issued_at();

        let cookie = encode_cookie(&key, &rng, issued_at, &payload_json);
        let (decoded_time, decoded_bytes, idx) =
            decode_cookie(&[key_from(IKM_PRIMARY)], &cookie).expect("round-trip must decode");

        assert_eq!(decoded_time, issued_at);
        assert_eq!(idx, 0);
        let decoded_payload: TestPayload =
            serde_json::from_slice(&decoded_bytes).expect("layer-2 JSON must parse");
        assert_eq!(decoded_payload, payload);
    }

    // --- AC1.2: complex shapes (nested structs, optionals, enums) -----------------

    /// seshcookie-rs.AC1.2: round-trip preserves complex shapes — structs with
    /// nested types, `Vec<_>` fields, `Option<_>` fields, and enum variants.
    /// Exercises every `Role` variant inside the `roles` vector and a
    /// populated `nested` field.
    #[test]
    fn round_trip_preserves_complex_shapes_ac1_2() {
        let rng = SystemRandom::new();
        let key = key_from(IKM_PRIMARY);

        let payload = TestPayload {
            user_id: u64::MAX,
            email: Some("user@example.test".into()),
            roles: vec![
                Role::Admin,
                Role::Member(0),
                Role::Member(u32::MAX),
                Role::Guest,
                Role::Admin,
            ],
            nested: Some(Inner {
                id: 99_999,
                tag: "deeply-nested-tag".into(),
            }),
        };
        let payload_json = serde_json::to_vec(&payload).expect("payload serializes");
        let issued_at = fixed_issued_at();

        let cookie = encode_cookie(&key, &rng, issued_at, &payload_json);
        let (_, decoded_bytes, _) =
            decode_cookie(&[key_from(IKM_PRIMARY)], &cookie).expect("round-trip must decode");

        let decoded_payload: TestPayload =
            serde_json::from_slice(&decoded_bytes).expect("layer-2 JSON must parse");
        assert_eq!(decoded_payload, payload);

        // Also verify the `email = None` and `nested = None` cases round-trip,
        // so all `Option` arms participate.
        let payload_with_nones = TestPayload {
            user_id: 1,
            email: None,
            roles: vec![Role::Guest],
            nested: None,
        };
        let payload_json = serde_json::to_vec(&payload_with_nones).expect("payload serializes");
        let cookie = encode_cookie(&key, &rng, issued_at, &payload_json);
        let (_, decoded_bytes, _) =
            decode_cookie(&[key_from(IKM_PRIMARY)], &cookie).expect("round-trip must decode");
        let decoded_payload: TestPayload =
            serde_json::from_slice(&decoded_bytes).expect("layer-2 JSON must parse");
        assert_eq!(decoded_payload, payload_with_nones);
    }

    // --- AC1.3: large payload stays under the 4 KB cookie cap ---------------------

    /// seshcookie-rs.AC1.3: a large JSON payload round-trips correctly, and the
    /// resulting base64-encoded cookie value is strictly under the 4 KB
    /// browser cap.
    ///
    /// The maximum plaintext that fits depends on encoding overhead:
    /// `(envelope_header(9) + plaintext + aead_overhead(28)) * 4 / 3 <= 4096`,
    /// which yields a plaintext bound of about 3035 bytes. We target ~3 KB of
    /// JSON — the largest realistic payload that still lands under the 4 KB
    /// cookie cap, matching the "arbitrary payloads up to 3 KB" guarantee in
    /// the Phase 1 acceptance criteria.
    ///
    /// (Note: the literal "3.5 KB" wording in the original AC1.3 prose is
    /// mathematically incompatible with the 4 KB cookie cap once base64url
    /// expansion and the envelope+AEAD overhead are accounted for; the Phase 1
    /// "Done when" checklist, the proptest payload bound, and this test all
    /// align on ~3 KB as the realistic target.)
    #[test]
    fn round_trip_large_payload_under_4kb_cookie_ac1_3() {
        let rng = SystemRandom::new();
        let key = key_from(IKM_PRIMARY);

        // Pad with a single string field. JSON encoding adds two surrounding
        // quotes plus the rest of the struct's overhead; we target 3000 bytes
        // of total JSON so the resulting cookie lands a few bytes under 4096.
        let target_json_len = 3000usize;
        // Discount struct overhead empirically: build once, measure, then scale
        // the pad length so the final JSON is at the target. The exact figure
        // doesn't matter; what matters is the JSON size lands in the
        // [target_json_len - 100, target_json_len + 100] range and the
        // resulting cookie is strictly under 4096.
        let probe = TestPayload {
            user_id: 7,
            email: Some(String::new()),
            roles: vec![Role::Admin],
            nested: Some(Inner {
                id: 2,
                tag: "n".into(),
            }),
        };
        let probe_json = serde_json::to_vec(&probe).expect("probe serializes");
        let pad_len = target_json_len.saturating_sub(probe_json.len());
        let payload = TestPayload {
            user_id: 7,
            email: Some("x".repeat(pad_len)),
            roles: vec![Role::Admin],
            nested: Some(Inner {
                id: 2,
                tag: "n".into(),
            }),
        };
        let payload_json = serde_json::to_vec(&payload).expect("payload serializes");
        assert!(
            (target_json_len.saturating_sub(100)..=target_json_len + 100)
                .contains(&payload_json.len()),
            "test fixture should produce roughly {target_json_len} bytes of JSON, got {}",
            payload_json.len()
        );

        let issued_at = fixed_issued_at();
        let cookie = encode_cookie(&key, &rng, issued_at, &payload_json);
        assert!(
            cookie.len() < 4096,
            "cookie {} bytes exceeds 4 KB browser cap",
            cookie.len()
        );

        let (_, decoded_bytes, _) =
            decode_cookie(&[key_from(IKM_PRIMARY)], &cookie).expect("round-trip must decode");
        let decoded_payload: TestPayload =
            serde_json::from_slice(&decoded_bytes).expect("layer-2 JSON must parse");
        assert_eq!(decoded_payload, payload);
    }

    // --- AC1.4: bit-flip tamper rejection at the cookie level ---------------------

    /// seshcookie-rs.AC1.4 (cookie-level): a single-byte tamper of the
    /// base64-decoded sealed bytes — anywhere in the nonce, ciphertext, or tag
    /// region — causes `decode_cookie` to return `None`. We re-base64-encode
    /// after each flip so the input the codec sees is well-formed base64; the
    /// failure must come from the AEAD layer, not from base64 rejection.
    ///
    /// Walks every byte position once, flipping bit 0 (low bit). One bit
    /// flipped per byte is sufficient to trip the AEAD tag check; the per-
    /// bit-position variant is covered by the AEAD-level test in `aead.rs`.
    #[test]
    fn tamper_rejects_every_byte_flip_ac1_4() {
        let rng = SystemRandom::new();
        let key = key_from(IKM_PRIMARY);
        let payload = sample_payload();
        let payload_json = serde_json::to_vec(&payload).expect("payload serializes");
        let issued_at = fixed_issued_at();

        let cookie = encode_cookie(&key, &rng, issued_at, &payload_json);
        let sealed_bytes = BASE64_URL_SAFE_NO_PAD
            .decode(&cookie)
            .expect("our own cookie must decode as base64");

        let keys = [key_from(IKM_PRIMARY)];

        for byte_index in 0..sealed_bytes.len() {
            let mut tampered_bytes = sealed_bytes.clone();
            tampered_bytes[byte_index] ^= 0x01;
            let tampered_cookie = BASE64_URL_SAFE_NO_PAD.encode(&tampered_bytes);
            let result = decode_cookie(&keys, &tampered_cookie);
            assert!(
                result.is_none(),
                "tamper at byte {byte_index} unexpectedly authenticated"
            );
        }
    }

    // --- AC4.2: cookie sealed under fallback decrypts via index 1 -----------------

    /// seshcookie-rs.AC4.2: a cookie sealed under fallback `O` decrypts via the
    /// keylist `[P, O]`, returning `decrypt_key_index = 1`.
    #[test]
    fn rotation_decrypts_via_first_fallback_ac4_2() {
        let rng = SystemRandom::new();
        let old_key = key_from(IKM_OTHER_1);
        let payload = sample_payload();
        let payload_json = serde_json::to_vec(&payload).expect("payload serializes");
        let issued_at = fixed_issued_at();

        let cookie = encode_cookie(&old_key, &rng, issued_at, &payload_json);

        let keys = [key_from(IKM_PRIMARY), key_from(IKM_OTHER_1)];
        let (decoded_time, decoded_bytes, idx) =
            decode_cookie(&keys, &cookie).expect("fallback must decrypt cookie sealed by O");
        assert_eq!(decoded_time, issued_at);
        assert_eq!(idx, 1);
        let decoded_payload: TestPayload =
            serde_json::from_slice(&decoded_bytes).expect("layer-2 JSON must parse");
        assert_eq!(decoded_payload, payload);
    }

    // --- AC4.6: cookie sealed under second fallback decrypts via index 2 ----------

    /// seshcookie-rs.AC4.6: a cookie sealed under the second fallback `O2`
    /// decrypts via the keylist `[P, O1, O2]`, returning index 2. Position
    /// within the fallbacks does not prevent a match.
    #[test]
    fn rotation_decrypts_via_second_fallback_ac4_6() {
        let rng = SystemRandom::new();
        let o2_key = key_from(IKM_OTHER_2);
        let payload = sample_payload();
        let payload_json = serde_json::to_vec(&payload).expect("payload serializes");
        let issued_at = fixed_issued_at();

        let cookie = encode_cookie(&o2_key, &rng, issued_at, &payload_json);

        let keys = [
            key_from(IKM_PRIMARY),
            key_from(IKM_OTHER_1),
            key_from(IKM_OTHER_2),
        ];
        let (decoded_time, decoded_bytes, idx) =
            decode_cookie(&keys, &cookie).expect("third key must authenticate");
        assert_eq!(decoded_time, issued_at);
        assert_eq!(idx, 2);
        let decoded_payload: TestPayload =
            serde_json::from_slice(&decoded_bytes).expect("layer-2 JSON must parse");
        assert_eq!(decoded_payload, payload);
    }

    // --- AC6.1: invalid base64 yields None ----------------------------------------

    /// seshcookie-rs.AC6.1: a cookie value that is not valid base64 returns
    /// `None`. Every `@` is outside the URL-safe base64 alphabet, so the
    /// decode step trips first and short-circuits to `None` before any AEAD
    /// or envelope work occurs.
    #[test]
    fn decode_returns_none_for_invalid_base64_ac6_1() {
        let key = key_from(IKM_PRIMARY);
        let result = decode_cookie(&[key], "@@@not-valid-base64@@@");
        assert!(result.is_none());
    }

    /// seshcookie-rs.AC6.1: a cookie with characters outside the URL-safe
    /// alphabet (`+` and `/` are *standard* base64, not URL-safe) is rejected.
    /// This guards against accidentally accepting a cookie minted under the
    /// wrong alphabet.
    #[test]
    fn decode_returns_none_for_standard_base64_alphabet_ac6_1() {
        let key = key_from(IKM_PRIMARY);
        // `+` is standard base64 but invalid in URL-safe alphabet.
        let result = decode_cookie(&[key], "AAAA+AAAA");
        assert!(result.is_none());
    }

    /// seshcookie-rs.AC6.1: an empty string base64-decodes to an empty byte
    /// slice, which is below the 28-byte AEAD minimum and therefore yields
    /// `None`. Confirms the decode pipeline composes correctly even when
    /// base64 succeeds vacuously.
    #[test]
    fn decode_returns_none_for_empty_string_ac6_1() {
        let key = key_from(IKM_PRIMARY);
        let result = decode_cookie(&[key], "");
        assert!(result.is_none());
    }

    // --- AC6.3: AEAD authentication failure yields None ---------------------------

    /// seshcookie-rs.AC6.3: a cookie that decodes from base64 cleanly but was
    /// sealed under a key absent from the trial list authentication-fails and
    /// returns `None`. No panic.
    #[test]
    fn decode_returns_none_when_no_key_authenticates_ac6_3() {
        let rng = SystemRandom::new();
        let unknown_key = key_from(IKM_UNKNOWN);
        let payload = sample_payload();
        let payload_json = serde_json::to_vec(&payload).expect("payload serializes");
        let issued_at = fixed_issued_at();

        let cookie = encode_cookie(&unknown_key, &rng, issued_at, &payload_json);

        let trial_keys = [key_from(IKM_PRIMARY), key_from(IKM_OTHER_1)];
        let result = decode_cookie(&trial_keys, &cookie);
        assert!(result.is_none());
    }

    // --- AC6.5: schema-mismatch JSON surfaces at deserialization, not decode ------

    /// seshcookie-rs.AC6.5 (bytes-level portion): a cookie whose plaintext is
    /// valid JSON but does not match the typed payload schema decodes
    /// successfully at the codec layer (cookie is well-formed and authentic).
    /// `serde_json::from_slice::<TestPayload>` surfaces the schema mismatch as
    /// `Err`. Phase 3 will translate that `Err` into `payload = None` for the
    /// handler.
    #[test]
    fn decode_returns_bytes_for_schema_mismatch_then_serde_errors_ac6_5() {
        let rng = SystemRandom::new();
        let key = key_from(IKM_PRIMARY);
        let issued_at = fixed_issued_at();

        // "just a string" is valid JSON but cannot deserialize into TestPayload.
        let valid_but_wrong_json =
            serde_json::to_vec(&"just a string").expect("string serializes to JSON");
        let cookie = encode_cookie(&key, &rng, issued_at, &valid_but_wrong_json);

        let (decoded_time, decoded_bytes, _) =
            decode_cookie(&[key_from(IKM_PRIMARY)], &cookie).expect("codec layer must succeed");
        assert_eq!(decoded_time, issued_at);
        // The bytes are exactly the JSON we put in.
        assert_eq!(decoded_bytes, valid_but_wrong_json);

        // Phase-3-equivalent step: deserializing into the typed payload fails.
        let typed_result: Result<TestPayload, _> = serde_json::from_slice(&decoded_bytes);
        assert!(typed_result.is_err());
    }

    // --- Property: round-trip under arbitrary rotation order (AC1.1, AC4.1, AC4.2,
    //               AC4.6) ---------------------------------------------------------

    proptest! {
        /// seshcookie-rs.AC1.1, seshcookie-rs.AC4.1, seshcookie-rs.AC4.2,
        /// seshcookie-rs.AC4.6: for any payload up to 3 KB and any number of
        /// fallback keys (0..=4), sealing under any position in the resulting
        /// keylist round-trips through `decode_cookie` with the matching index.
        ///
        /// Bound on `payload_bytes` matches Phase 1's "Done when" wording
        /// ("arbitrary payloads up to 3 KB"). `extra_keys` provides 0..=4
        /// additional fallback IKMs of length 16..32 (the 16-byte minimum from
        /// `MIN_KEY_BYTES` with some headroom). `sealing_index` is taken
        /// modulo the keylist length, so it picks any valid position.
        #[test]
        fn prop_roundtrip_under_any_rotation_order(
            payload_bytes in prop::collection::vec(any::<u8>(), 0..3072),
            extra_keys in prop::collection::vec(
                prop::collection::vec(any::<u8>(), 16..32),
                0..4usize,
            ),
            sealing_index in 0usize..5,
        ) {
            let rng = SystemRandom::new();
            let primary_ikm = [0x11u8; 16];
            let all_ikms: Vec<Vec<u8>> = std::iter::once(primary_ikm.to_vec())
                .chain(extra_keys.into_iter())
                .collect();
            let idx = sealing_index % all_ikms.len();
            let keys: Vec<DerivedKey> =
                all_ikms.iter().map(|k| DerivedKey::derive(k)).collect();

            let issued_at = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000);
            let cookie = encode_cookie(&keys[idx], &rng, issued_at, &payload_bytes);
            let (t, bytes, returned_idx) =
                decode_cookie(&keys, &cookie).expect("encoded cookie must decode");

            prop_assert_eq!(t, issued_at);
            prop_assert_eq!(bytes, payload_bytes);
            prop_assert_eq!(returned_idx, idx);
        }
    }
}