Skip to main content

kk_crypto/
temporal.rs

1// Copyright (c) 2026 John A Keeney, Entrouter. All rights reserved.
2// Licensed under the Apache License, Version 2.0 with Additional Terms.
3// NO COMMERCIAL USE without prior written authorization from Entrouter.
4// Unauthorized commercial use will be prosecuted to the fullest extent of the law.
5// See the LICENSE file in the project root for full license information.
6// NOTICE: Removal of this header is a violation of the license.
7
8//! Temporal commitment and proof system for KK.
9//!
10//! Two tiers:
11//!
12//! ## `TemporalCommitment` (basic, the original API)
13//!
14//! A standard MAC binding (ciphertext, entropy snapshot, shared secret).
15//! Guarantees **integrity** and **authentication**, but:
16//!   - Does NOT prove *when* the commitment was created (sender controls timestamp)
17//!   - Does NOT prevent replay (same triple verifies forever)
18//!   - MAC uses default rotations (not temporal-variant)
19//!
20//! Use when you only need tamper detection between cooperating parties.
21//!
22//! ## `TemporalProof` (bound, the real thing)
23//!
24//! A challenge-response commitment with four verifiable properties:
25//!
26//! | Property   | Mechanism                                           |
27//! |------------|-----------------------------------------------------|
28//! | Integrity  | MAC over (nonce ‖ prev_mac ‖ ε ‖ ciphertext)       |
29//! | Freshness  | Verifier-supplied nonce proves creation was *after* it was issued |
30//! | Recency    | Verifier's clock checks `|now - ε.timestamp| < max_drift` |
31//! | Ordering   | `prev_mac` chains proofs into a total order         |
32//!
33//! The MAC uses **entropy-derived rotations**, so the permutation that
34//! produced the tag only existed at that entropic moment, the algebra
35//! itself is temporal.
36//!
37//! Built entirely from the KK permutation, no HMAC, no SHA-256.
38
39use rand::RngCore;
40use std::time::Duration;
41
42use crate::entropy::EntropySnapshot;
43use crate::error::{KkError, Result};
44use crate::kdf;
45use crate::kk_mix;
46use zeroize::Zeroize;
47
48/// The prev_mac value for the first proof in a chain.
49pub const GENESIS_MAC: [u8; 32] = [0u8; 32];
50
51// ─────────────────────────────────────────────────────────────────
52//  Basic commitment (original API, preserved for backward compat)
53// ─────────────────────────────────────────────────────────────────
54
55/// A basic MAC commitment binding ciphertext to its entropy snapshot.
56///
57/// **What this proves:**
58///   - The entropy snapshot ε was used with this shared secret
59///   - The ciphertext hasn't been tampered with
60///
61/// **What this does NOT prove:**
62///   - When the commitment was created (sender controls the timestamp)
63///   - That this is a fresh commitment (replays verify indefinitely)
64///
65/// For provable temporal guarantees, use [`TemporalProof`] instead.
66#[derive(Clone, Debug)]
67pub struct TemporalCommitment {
68    pub mac: [u8; 32],
69}
70
71impl TemporalCommitment {
72    pub fn to_bytes(&self) -> Vec<u8> {
73        self.mac.to_vec()
74    }
75
76    pub fn from_bytes(data: &[u8]) -> Result<Self> {
77        if data.len() < 32 {
78            return Err(KkError::InvalidPacket("commitment too short".into()));
79        }
80        let mut mac = [0u8; 32];
81        mac.copy_from_slice(&data[..32]);
82        Ok(Self { mac })
83    }
84}
85
86/// Create a basic commitment (integrity only, no temporal proof).
87///
88/// See [`TemporalCommitment`] for what this does and does not prove.
89pub fn commit(
90    shared_secret: &[u8],
91    snapshot: &EntropySnapshot,
92    ciphertext: &[u8],
93) -> Result<TemporalCommitment> {
94    let mut commit_key = kdf::derive_commitment_key(shared_secret, snapshot)?;
95
96    let mut message = Vec::with_capacity(32 + 16 + ciphertext.len());
97    message.extend_from_slice(&snapshot.bytes);
98    message.extend_from_slice(&snapshot.timestamp_nanos.to_le_bytes());
99    message.extend_from_slice(ciphertext);
100
101    let mac_bytes = kk_mix::kk_mac(&commit_key, &message);
102    commit_key.zeroize();
103
104    Ok(TemporalCommitment { mac: mac_bytes })
105}
106
107/// Verify a basic commitment (integrity only).
108pub fn verify(
109    shared_secret: &[u8],
110    snapshot: &EntropySnapshot,
111    ciphertext: &[u8],
112    commitment: &TemporalCommitment,
113) -> Result<()> {
114    let mut commit_key = kdf::derive_commitment_key(shared_secret, snapshot)?;
115
116    let mut message = Vec::with_capacity(32 + 16 + ciphertext.len());
117    message.extend_from_slice(&snapshot.bytes);
118    message.extend_from_slice(&snapshot.timestamp_nanos.to_le_bytes());
119    message.extend_from_slice(ciphertext);
120
121    let verified = kk_mix::kk_mac_verify(&commit_key, &message, &commitment.mac);
122    commit_key.zeroize();
123
124    if verified {
125        Ok(())
126    } else {
127        Err(KkError::CommitmentMismatch)
128    }
129}
130
131// ─────────────────────────────────────────────────────────────────
132//  AEAD Commitment (integrity + associated data)
133// ─────────────────────────────────────────────────────────────────
134
135/// Create an AEAD commitment binding ciphertext, entropy, AND associated data.
136///
137/// The MAC message is:
138/// `snapshot.bytes(32B) || timestamp_nanos(16B LE) || aad_len(8B LE) || aad || ciphertext`
139///
140/// This ensures the AAD is authenticated alongside the ciphertext-any
141/// modification to either is detected.
142pub fn commit_aead(
143    shared_secret: &[u8],
144    snapshot: &EntropySnapshot,
145    ciphertext: &[u8],
146    aad: &[u8],
147) -> Result<TemporalCommitment> {
148    let mut commit_key = kdf::derive_commitment_key(shared_secret, snapshot)?;
149
150    let aad_len = aad.len() as u64;
151    let mut message = Vec::with_capacity(32 + 16 + 8 + aad.len() + ciphertext.len());
152    message.extend_from_slice(&snapshot.bytes);
153    message.extend_from_slice(&snapshot.timestamp_nanos.to_le_bytes());
154    message.extend_from_slice(&aad_len.to_le_bytes());
155    message.extend_from_slice(aad);
156    message.extend_from_slice(ciphertext);
157
158    let mac_bytes = kk_mix::kk_mac(&commit_key, &message);
159    commit_key.zeroize();
160
161    Ok(TemporalCommitment { mac: mac_bytes })
162}
163
164/// Create 8 AEAD commitments simultaneously using batch MAC.
165///
166/// Identical semantics to calling [`commit_aead`] 8 times, but uses
167/// AVX-512 batch MAC when available for ~6-8× throughput on the MAC phase.
168pub fn commit_aead_batch_8(
169    shared_secret: &[u8],
170    snapshots: [&EntropySnapshot; 8],
171    ciphertexts: [&[u8]; 8],
172    aads: [&[u8]; 8],
173) -> Result<[TemporalCommitment; 8]> {
174    // Derive 8 commitment keys (scalar - each is a single KDF, tiny)
175    let mut commit_keys: [Vec<u8>; 8] = core::array::from_fn(|i| {
176        kdf::derive_commitment_key(shared_secret, snapshots[i])
177            .expect("commitment key derivation should not fail")
178    });
179
180    // Build 8 small MAC prefixes (header only - no ciphertext copy)
181    let prefixes: [Vec<u8>; 8] = core::array::from_fn(|i| {
182        let aad_len = aads[i].len() as u64;
183        let mut prefix = Vec::with_capacity(48 + aads[i].len());
184        prefix.extend_from_slice(&snapshots[i].bytes);
185        prefix.extend_from_slice(&snapshots[i].timestamp_nanos.to_le_bytes());
186        prefix.extend_from_slice(&aad_len.to_le_bytes());
187        prefix.extend_from_slice(aads[i]);
188        prefix
189    });
190
191    let key_refs: [&[u8]; 8] = core::array::from_fn(|i| commit_keys[i].as_slice());
192    let prefix_refs: [&[u8]; 8] = core::array::from_fn(|i| prefixes[i].as_slice());
193
194    let macs = kk_mix::kk_mac_batch_8_multipart(key_refs, prefix_refs, ciphertexts);
195
196    for k in &mut commit_keys {
197        k.zeroize();
198    }
199
200    Ok(core::array::from_fn(|i| TemporalCommitment {
201        mac: macs[i],
202    }))
203}
204
205/// Verify an AEAD commitment (integrity + associated data).
206pub fn verify_aead(
207    shared_secret: &[u8],
208    snapshot: &EntropySnapshot,
209    ciphertext: &[u8],
210    aad: &[u8],
211    commitment: &TemporalCommitment,
212) -> Result<()> {
213    let mut commit_key = kdf::derive_commitment_key(shared_secret, snapshot)?;
214
215    let aad_len = aad.len() as u64;
216    let mut message = Vec::with_capacity(32 + 16 + 8 + aad.len() + ciphertext.len());
217    message.extend_from_slice(&snapshot.bytes);
218    message.extend_from_slice(&snapshot.timestamp_nanos.to_le_bytes());
219    message.extend_from_slice(&aad_len.to_le_bytes());
220    message.extend_from_slice(aad);
221    message.extend_from_slice(ciphertext);
222
223    let verified = kk_mix::kk_mac_verify(&commit_key, &message, &commitment.mac);
224    commit_key.zeroize();
225
226    if verified {
227        Ok(())
228    } else {
229        Err(KkError::CommitmentMismatch)
230    }
231}
232
233// ─────────────────────────────────────────────────────────────────
234//  Temporal Proof (the real thing)
235// ─────────────────────────────────────────────────────────────────
236
237/// A temporal proof with verifiable freshness, recency, and ordering.
238///
239/// ## What this proves
240///
241/// 1. **Integrity**, the ciphertext and entropy snapshot have not been
242///    modified since the proof was created.
243/// 2. **Freshness**, the proof was created *after* the verifier issued
244///    its challenge nonce (prevents replay).
245/// 3. **Recency**, the claimed `ε.timestamp` is within `max_drift` of
246///    the verifier's clock at verification time.
247/// 4. **Ordering**, if `prev_mac` is non-genesis, this proof was created
248///    after the proof whose MAC it references.
249///
250/// ## How it works
251///
252/// ```text
253/// commit_key = KK-KDF(shared_secret, ε.bytes, "KK-commit-v1")
254/// message    = nonce || prev_mac || ε.bytes || ε.timestamp || ciphertext
255/// mac        = KK-MAC-with-entropy(commit_key, message, ε.bytes)
256/// ```
257///
258/// The MAC runs on a sponge whose *rotation schedule* is derived from
259/// `ε.bytes`, the permutation structure itself is temporal, not just
260/// the data flowing through it.
261///
262/// ## Protocol
263///
264/// ```text
265/// Verifier ──── challenge nonce ──→ Prover
266/// Prover   ──── KkBoundPacket   ──→ Verifier
267/// Verifier checks: MAC ✓  epoch ✓  nonce ✓  chain ✓
268/// ```
269#[derive(Clone, Debug)]
270pub struct TemporalProof {
271    /// MAC binding nonce + chain + entropy + ciphertext.
272    pub mac: [u8; 32],
273    /// Verifier-supplied freshness nonce (prevents replay).
274    pub nonce: [u8; 32],
275    /// MAC of the previous proof in the chain ([`GENESIS_MAC`] for the first).
276    pub prev_mac: [u8; 32],
277}
278
279impl TemporalProof {
280    /// Serialized size in bytes: 32 (mac) + 32 (nonce) + 32 (prev_mac).
281    pub const BYTES: usize = 96;
282
283    pub fn to_bytes(&self) -> Vec<u8> {
284        let mut out = Vec::with_capacity(Self::BYTES);
285        out.extend_from_slice(&self.mac);
286        out.extend_from_slice(&self.nonce);
287        out.extend_from_slice(&self.prev_mac);
288        out
289    }
290
291    pub fn from_bytes(data: &[u8]) -> Result<Self> {
292        if data.len() < Self::BYTES {
293            return Err(KkError::InvalidPacket(format!(
294                "temporal proof too short: need {}, got {}",
295                Self::BYTES,
296                data.len()
297            )));
298        }
299        let mut mac = [0u8; 32];
300        let mut nonce = [0u8; 32];
301        let mut prev_mac = [0u8; 32];
302        mac.copy_from_slice(&data[..32]);
303        nonce.copy_from_slice(&data[32..64]);
304        prev_mac.copy_from_slice(&data[64..96]);
305        Ok(Self {
306            mac,
307            nonce,
308            prev_mac,
309        })
310    }
311}
312
313/// Generate a cryptographic challenge nonce for the verifier.
314///
315/// The verifier calls this, sends the nonce to the prover, and later
316/// checks that the proof contains it. This proves the proof was created
317/// *after* the nonce was issued.
318///
319/// The verifier MUST track issued nonces and reject duplicates to prevent
320/// replay. Each nonce should be used exactly once.
321pub fn generate_challenge() -> Result<[u8; 32]> {
322    let mut nonce = [0u8; 32];
323    rand::rngs::OsRng
324        .try_fill_bytes(&mut nonce)
325        .map_err(|e| KkError::EntropyFailure(format!("nonce generation: {e}")))?;
326    Ok(nonce)
327}
328
329/// Create a temporal proof with verifiable freshness and ordering.
330///
331/// # Arguments
332/// - `shared_secret`, the pre-shared key
333/// - `snapshot`, the entropy snapshot captured during encoding
334/// - `ciphertext`, the encoded bytes
335/// - `verifier_nonce`, the challenge nonce from the verifier
336/// - `prev_mac`, MAC of the previous proof in the chain, or
337///   [`GENESIS_MAC`] for the first proof
338pub fn commit_bound(
339    shared_secret: &[u8],
340    snapshot: &EntropySnapshot,
341    ciphertext: &[u8],
342    verifier_nonce: &[u8; 32],
343    prev_mac: &[u8; 32],
344) -> Result<TemporalProof> {
345    let mut commit_key = kdf::derive_commitment_key(shared_secret, snapshot)?;
346
347    // Build the bound message: nonce || prev_mac || ε.bytes || ε.timestamp || ciphertext
348    let mut message = Vec::with_capacity(32 + 32 + 32 + 16 + ciphertext.len());
349    message.extend_from_slice(verifier_nonce);
350    message.extend_from_slice(prev_mac);
351    message.extend_from_slice(&snapshot.bytes);
352    message.extend_from_slice(&snapshot.timestamp_nanos.to_le_bytes());
353    message.extend_from_slice(ciphertext);
354
355    // MAC with entropy-derived rotations, the algebra itself is temporal
356    let mac_bytes = kk_mix::kk_mac_with_entropy(&commit_key, &message, &snapshot.bytes);
357    commit_key.zeroize();
358
359    Ok(TemporalProof {
360        mac: mac_bytes,
361        nonce: *verifier_nonce,
362        prev_mac: *prev_mac,
363    })
364}
365
366/// Verify a temporal proof: integrity + freshness + recency + chain.
367///
368/// # Arguments
369/// - `shared_secret`, the pre-shared key
370/// - `snapshot`, the entropy snapshot from the packet
371/// - `ciphertext`, the encoded bytes from the packet
372/// - `proof`, the temporal proof to verify
373/// - `expected_nonce`, the nonce the verifier originally issued
374/// - `max_drift`, maximum acceptable clock drift
375///
376/// # Verification steps
377/// 1. **Nonce match**, `proof.nonce == expected_nonce` (freshness)
378/// 2. **Epoch check**, `|now - ε.timestamp| ≤ max_drift` (recency)
379/// 3. **MAC verify**, recompute and constant-time compare (integrity)
380///
381/// The caller is responsible for:
382/// - Tracking issued nonces and rejecting reuse ([`KkError::StaleNonce`])
383/// - Verifying `proof.prev_mac` matches the previous proof's MAC (chain)
384pub fn verify_bound(
385    shared_secret: &[u8],
386    snapshot: &EntropySnapshot,
387    ciphertext: &[u8],
388    proof: &TemporalProof,
389    expected_nonce: &[u8; 32],
390    max_drift: Duration,
391) -> Result<()> {
392    // 1. Freshness: nonce must match the one we issued
393    if proof.nonce != *expected_nonce {
394        return Err(KkError::StaleNonce);
395    }
396
397    // 2. Recency: claimed timestamp must be within max_drift of our clock
398    let now_nanos = std::time::SystemTime::now()
399        .duration_since(std::time::UNIX_EPOCH)
400        .unwrap_or_default()
401        .as_nanos();
402
403    let drift = now_nanos.abs_diff(snapshot.timestamp_nanos);
404
405    if drift > max_drift.as_nanos() {
406        return Err(KkError::EpochDrift {
407            claimed_nanos: snapshot.timestamp_nanos,
408            drift_nanos: drift,
409            max_nanos: max_drift.as_nanos(),
410        });
411    }
412
413    // 3. Integrity: recompute MAC with entropy-derived rotations
414    let mut commit_key = kdf::derive_commitment_key(shared_secret, snapshot)?;
415
416    let mut message = Vec::with_capacity(32 + 32 + 32 + 16 + ciphertext.len());
417    message.extend_from_slice(&proof.nonce);
418    message.extend_from_slice(&proof.prev_mac);
419    message.extend_from_slice(&snapshot.bytes);
420    message.extend_from_slice(&snapshot.timestamp_nanos.to_le_bytes());
421    message.extend_from_slice(ciphertext);
422
423    let verified =
424        kk_mix::kk_mac_verify_with_entropy(&commit_key, &message, &proof.mac, &snapshot.bytes);
425    commit_key.zeroize();
426
427    if verified {
428        Ok(())
429    } else {
430        Err(KkError::CommitmentMismatch)
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::entropy;
438
439    // ── Basic commitment tests (preserved) ──
440
441    #[test]
442    fn valid_commitment_verifies() {
443        let secret = b"test-key";
444        let snap = entropy::gather().unwrap();
445        let ciphertext = b"some ciphertext bytes";
446
447        let commitment = commit(secret, &snap, ciphertext).unwrap();
448        verify(secret, &snap, ciphertext, &commitment).unwrap();
449    }
450
451    #[test]
452    fn tampered_ciphertext_fails() {
453        let secret = b"test-key";
454        let snap = entropy::gather().unwrap();
455        let ciphertext = b"original ciphertext";
456
457        let commitment = commit(secret, &snap, ciphertext).unwrap();
458
459        let tampered = b"tampered ciphertext";
460        let result = verify(secret, &snap, tampered, &commitment);
461        assert!(
462            result.is_err(),
463            "Tampered ciphertext must fail verification"
464        );
465    }
466
467    #[test]
468    fn wrong_key_fails() {
469        let snap = entropy::gather().unwrap();
470        let ciphertext = b"test data";
471
472        let commitment = commit(b"correct-key", &snap, ciphertext).unwrap();
473        let result = verify(b"wrong-key", &snap, ciphertext, &commitment);
474        assert!(
475            result.is_err(),
476            "Wrong shared secret must fail verification"
477        );
478    }
479
480    // ── Temporal proof tests ──
481
482    #[test]
483    fn bound_proof_verifies() {
484        let secret = b"test-key";
485        let snap = entropy::gather().unwrap();
486        let ciphertext = b"bound ciphertext";
487        let nonce = generate_challenge().unwrap();
488
489        let proof = commit_bound(secret, &snap, ciphertext, &nonce, &GENESIS_MAC).unwrap();
490        verify_bound(
491            secret,
492            &snap,
493            ciphertext,
494            &proof,
495            &nonce,
496            Duration::from_secs(5),
497        )
498        .unwrap();
499    }
500
501    #[test]
502    fn wrong_nonce_rejected() {
503        let secret = b"test-key";
504        let snap = entropy::gather().unwrap();
505        let ciphertext = b"nonce test";
506        let real_nonce = generate_challenge().unwrap();
507        let fake_nonce = generate_challenge().unwrap();
508
509        let proof = commit_bound(secret, &snap, ciphertext, &real_nonce, &GENESIS_MAC).unwrap();
510        let result = verify_bound(
511            secret,
512            &snap,
513            ciphertext,
514            &proof,
515            &fake_nonce,
516            Duration::from_secs(5),
517        );
518        assert!(
519            matches!(result, Err(KkError::StaleNonce)),
520            "Wrong nonce must be rejected as StaleNonce"
521        );
522    }
523
524    #[test]
525    fn tampered_ciphertext_fails_bound() {
526        let secret = b"test-key";
527        let snap = entropy::gather().unwrap();
528        let nonce = generate_challenge().unwrap();
529
530        let proof = commit_bound(secret, &snap, b"original", &nonce, &GENESIS_MAC).unwrap();
531        let result = verify_bound(
532            secret,
533            &snap,
534            b"tampered",
535            &proof,
536            &nonce,
537            Duration::from_secs(5),
538        );
539        assert!(
540            result.is_err(),
541            "Tampered ciphertext must fail bound verification"
542        );
543    }
544
545    #[test]
546    fn epoch_drift_rejected() {
547        let secret = b"test-key";
548        let ciphertext = b"epoch test";
549        let nonce = generate_challenge().unwrap();
550
551        // Create a snapshot with a timestamp far in the past
552        let real_snap = entropy::gather().unwrap();
553        let old_snap = EntropySnapshot {
554            bytes: real_snap.bytes,
555            timestamp_nanos: 1_000_000_000_000_000_000, // ~2001
556        };
557
558        let proof = commit_bound(secret, &old_snap, ciphertext, &nonce, &GENESIS_MAC).unwrap();
559        let result = verify_bound(
560            secret,
561            &old_snap,
562            ciphertext,
563            &proof,
564            &nonce,
565            Duration::from_secs(5),
566        );
567        assert!(
568            matches!(result, Err(KkError::EpochDrift { .. })),
569            "Ancient timestamp must be rejected as EpochDrift"
570        );
571    }
572
573    #[test]
574    fn chain_ordering() {
575        let secret = b"chain-key";
576        let nonce1 = generate_challenge().unwrap();
577        let nonce2 = generate_challenge().unwrap();
578
579        // Proof 1 (genesis)
580        let snap1 = entropy::gather().unwrap();
581        let ct1 = b"message one";
582        let proof1 = commit_bound(secret, &snap1, ct1, &nonce1, &GENESIS_MAC).unwrap();
583        verify_bound(
584            secret,
585            &snap1,
586            ct1,
587            &proof1,
588            &nonce1,
589            Duration::from_secs(5),
590        )
591        .unwrap();
592
593        // Proof 2 (chained to proof 1)
594        let snap2 = entropy::gather().unwrap();
595        let ct2 = b"message two";
596        let proof2 = commit_bound(secret, &snap2, ct2, &nonce2, &proof1.mac).unwrap();
597        verify_bound(
598            secret,
599            &snap2,
600            ct2,
601            &proof2,
602            &nonce2,
603            Duration::from_secs(5),
604        )
605        .unwrap();
606
607        // Verify the chain link
608        assert_eq!(
609            proof2.prev_mac, proof1.mac,
610            "Proof 2 must reference Proof 1's MAC"
611        );
612        assert_eq!(
613            proof1.prev_mac, GENESIS_MAC,
614            "Proof 1 must reference genesis"
615        );
616    }
617
618    #[test]
619    fn proof_serde_roundtrip() {
620        let secret = b"serde-key";
621        let snap = entropy::gather().unwrap();
622        let nonce = generate_challenge().unwrap();
623
624        let proof = commit_bound(secret, &snap, b"serde test", &nonce, &GENESIS_MAC).unwrap();
625        let bytes = proof.to_bytes();
626        assert_eq!(bytes.len(), TemporalProof::BYTES);
627
628        let restored = TemporalProof::from_bytes(&bytes).unwrap();
629        assert_eq!(restored.mac, proof.mac);
630        assert_eq!(restored.nonce, proof.nonce);
631        assert_eq!(restored.prev_mac, proof.prev_mac);
632    }
633
634    #[test]
635    fn wrong_prev_mac_fails() {
636        let secret = b"chain-key";
637        let snap = entropy::gather().unwrap();
638        let nonce = generate_challenge().unwrap();
639        let ciphertext = b"chain integrity";
640
641        // Create with one prev_mac
642        let proof = commit_bound(secret, &snap, ciphertext, &nonce, &GENESIS_MAC).unwrap();
643
644        // Forge a different prev_mac in the proof
645        let mut forged = proof.clone();
646        forged.prev_mac = [0xFF; 32];
647
648        // MAC check will fail because the message includes prev_mac
649        let result = verify_bound(
650            secret,
651            &snap,
652            ciphertext,
653            &forged,
654            &nonce,
655            Duration::from_secs(5),
656        );
657        assert!(
658            result.is_err(),
659            "Forged prev_mac must fail MAC verification"
660        );
661    }
662}