Skip to main content

tf_types/
constrained.rs

1#![allow(clippy::let_and_return)]
2//! Constrained-mode runtime primitives — Rust mirror of
3//! `tools/tf-types-ts/src/core/constrained.ts`.
4//!
5//! Constrained deployments (LoRa mesh, air-gapped relays, USB-shuttle,
6//! intermittent satellites) need anti-replay protection on the
7//! receiver, a way to honour offline revocations without phoning home,
8//! delivery receipts so packets sent over a one-way bearer can prove
9//! they arrived, and proof-of-forwarding receipts so a relay can show
10//! it actually carried a packet without seeing its plaintext.
11
12use std::collections::{HashMap, VecDeque};
13
14use crate::encoding::STANDARD;
15use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::canonicalize;
20use crate::generated::offline_revocation_list::{
21    OfflineRevocationList, OfflineRevocationList_ListVersion, RevokedEntry, RevokedEntry_Kind,
22};
23use crate::packet::Packet;
24
25/* -------------------------------------------------------------------------- */
26/*  PacketReceiver — sliding-window nonce cache                               */
27/* -------------------------------------------------------------------------- */
28
29#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
30#[serde(rename_all = "kebab-case")]
31pub enum PacketRejectReason {
32    Replay,
33    Expired,
34    FutureDated,
35}
36
37#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(tag = "kind", rename_all = "kebab-case")]
39pub enum PacketReceiverDecision {
40    Accept,
41    Reject { reason: PacketRejectReason },
42}
43
44#[derive(Debug)]
45pub struct PacketReceiver {
46    /// `(packet_id, expires_at)` pairs in insertion order. We push to
47    /// the back, evict from the front for LRU semantics.
48    seen: VecDeque<(String, String)>,
49    /// Set membership for O(1) replay checks. Mirrors `seen`.
50    index: HashMap<String, ()>,
51    window_size: usize,
52}
53
54impl PacketReceiver {
55    pub fn new(window_size: Option<usize>) -> Self {
56        let window_size = window_size.unwrap_or(4096);
57        PacketReceiver {
58            seen: VecDeque::with_capacity(window_size),
59            index: HashMap::with_capacity(window_size),
60            window_size,
61        }
62    }
63
64    /// Check + record a packet. Pure decision; if you call this twice
65    /// with the same packet, the second call returns `Replay`.
66    pub fn observe(&mut self, packet: &Packet, now: &str) -> PacketReceiverDecision {
67        if let Some(exp) = packet.expires_at.as_deref() {
68            if exp < now {
69                return PacketReceiverDecision::Reject {
70                    reason: PacketRejectReason::Expired,
71                };
72            }
73        }
74        if packet.created_at.as_str() > now {
75            return PacketReceiverDecision::Reject {
76                reason: PacketRejectReason::FutureDated,
77            };
78        }
79        if self.index.contains_key(&packet.packet_id) {
80            return PacketReceiverDecision::Reject {
81                reason: PacketRejectReason::Replay,
82            };
83        }
84        if self.seen.len() >= self.window_size {
85            if let Some((oldest, _)) = self.seen.pop_front() {
86                self.index.remove(&oldest);
87            }
88        }
89        self.seen.push_back((
90            packet.packet_id.clone(),
91            packet.expires_at.clone().unwrap_or_default(),
92        ));
93        self.index.insert(packet.packet_id.clone(), ());
94        PacketReceiverDecision::Accept
95    }
96
97    /// Drop entries whose recorded `expires_at` is `< before`. Useful
98    /// at start-of-tick on a receiver that wants the window to follow
99    /// real time rather than just LRU.
100    pub fn expire_older_than(&mut self, before: &str) -> usize {
101        let mut removed = 0usize;
102        // Walk from the front; entries with non-empty exp older than
103        // `before` are dropped. Stop on first entry that should stay
104        // — but since we may have unsorted exp values we instead keep
105        // a fresh deque of survivors.
106        let mut survivors: VecDeque<(String, String)> = VecDeque::with_capacity(self.seen.len());
107        let mut new_index: HashMap<String, ()> = HashMap::with_capacity(self.seen.len());
108        for entry in self.seen.drain(..) {
109            let drop = !entry.1.is_empty() && entry.1.as_str() < before;
110            if drop {
111                removed += 1;
112            } else {
113                new_index.insert(entry.0.clone(), ());
114                survivors.push_back(entry);
115            }
116        }
117        self.seen = survivors;
118        self.index = new_index;
119        removed
120    }
121
122    pub fn size(&self) -> usize {
123        self.seen.len()
124    }
125}
126
127/* -------------------------------------------------------------------------- */
128/*  OfflineRevocationListRuntime — sealed-list verifier                       */
129/* -------------------------------------------------------------------------- */
130
131#[derive(Debug, thiserror::Error)]
132pub enum OrlError {
133    #[error("offline revocation list version unsupported")]
134    UnsupportedVersion,
135    #[error("offline revocation list expired at {0}")]
136    Expired(String),
137    #[error("offline revocation list dated in the future: {0}")]
138    FutureDated(String),
139    #[error("offline revocation list signature did not verify")]
140    BadSignature,
141    #[error("signature decode: {0}")]
142    SignatureDecode(String),
143    #[error("verifying key: {0}")]
144    VerifyingKey(String),
145    #[error("canonicalize: {0}")]
146    Canonicalize(String),
147}
148
149#[derive(Debug)]
150pub struct OfflineRevocationListRuntime {
151    list: OfflineRevocationList,
152    /// `"<kind>:<id>"` → entry index for O(1) lookup. We collapse to a
153    /// string key because `RevokedEntry_Kind` is generated and can't
154    /// derive `Hash` without changing the codegen.
155    index: HashMap<String, RevokedEntry>,
156}
157
158fn revoked_entry_kind_str(k: &RevokedEntry_Kind) -> &'static str {
159    match k {
160        RevokedEntry_Kind::Actor => "actor",
161        RevokedEntry_Kind::Instance => "instance",
162        RevokedEntry_Kind::Capability => "capability",
163        RevokedEntry_Kind::Delegation => "delegation",
164        RevokedEntry_Kind::Key => "key",
165    }
166}
167
168impl OfflineRevocationListRuntime {
169    /// Build the runtime AFTER verifying the issuer signature. Refuses
170    /// to construct if the signature does not validate, the list has
171    /// expired (`valid_until` < now), or the list version is unknown.
172    pub fn load(
173        list: OfflineRevocationList,
174        issuer_public_key: &[u8; 32],
175        now: &str,
176    ) -> Result<Self, OrlError> {
177        if list.list_version != OfflineRevocationList_ListVersion::V1 {
178            return Err(OrlError::UnsupportedVersion);
179        }
180        if list.valid_until.as_str() < now {
181            return Err(OrlError::Expired(list.valid_until.clone()));
182        }
183        if list.issued_at.as_str() > now {
184            return Err(OrlError::FutureDated(list.issued_at.clone()));
185        }
186        if !verify_offline_revocation_list_signature(&list, issuer_public_key)? {
187            return Err(OrlError::BadSignature);
188        }
189        let mut index: HashMap<String, RevokedEntry> = HashMap::new();
190        for e in &list.revoked {
191            index.insert(
192                format!("{}:{}", revoked_entry_kind_str(&e.kind), e.id),
193                e.clone(),
194            );
195        }
196        Ok(OfflineRevocationListRuntime { list, index })
197    }
198
199    /// Was a target revoked by this list?
200    pub fn is_revoked(&self, kind: &RevokedEntry_Kind, id: &str) -> Option<&RevokedEntry> {
201        self.index
202            .get(&format!("{}:{}", revoked_entry_kind_str(kind), id))
203    }
204
205    pub fn metadata(&self) -> OrlMetadata<'_> {
206        OrlMetadata {
207            issuer: &self.list.issuer,
208            trust_domain: &self.list.trust_domain,
209            issued_at: &self.list.issued_at,
210            valid_until: &self.list.valid_until,
211        }
212    }
213}
214
215#[derive(Debug)]
216pub struct OrlMetadata<'a> {
217    pub issuer: &'a str,
218    pub trust_domain: &'a str,
219    pub issued_at: &'a str,
220    pub valid_until: &'a str,
221}
222
223pub fn verify_offline_revocation_list_signature(
224    list: &OfflineRevocationList,
225    public_key: &[u8; 32],
226) -> Result<bool, OrlError> {
227    let mut value = serde_json::to_value(list).unwrap_or(serde_json::Value::Null);
228    if let serde_json::Value::Object(map) = &mut value {
229        map.remove("signature");
230    }
231    let canonical = canonicalize(&value).map_err(|e| OrlError::Canonicalize(e.to_string()))?;
232    let sig_bytes = STANDARD
233        .decode(&list.signature.signature)
234        .map_err(|e| OrlError::SignatureDecode(e.to_string()))?;
235    let sig = match Signature::from_slice(&sig_bytes) {
236        Ok(s) => s,
237        Err(_) => return Ok(false),
238    };
239    let vk =
240        VerifyingKey::from_bytes(public_key).map_err(|e| OrlError::VerifyingKey(e.to_string()))?;
241    Ok(vk.verify(canonical.as_bytes(), &sig).is_ok())
242}
243
244pub fn sign_offline_revocation_list(
245    mut list: OfflineRevocationList,
246    private_key: &[u8; 32],
247) -> Result<OfflineRevocationList, OrlError> {
248    // Zero out signature before canonicalising.
249    list.signature = crate::generated::common::SignatureEnvelope {
250        algorithm: list.signature.algorithm.clone(),
251        signer: list.signature.signer.clone(),
252        signature: String::new(),
253        hash_alg: None,
254        alt_algorithm: None,
255        alt_signature: None,
256    };
257    let mut value = serde_json::to_value(&list).unwrap_or(serde_json::Value::Null);
258    if let serde_json::Value::Object(map) = &mut value {
259        map.remove("signature");
260    }
261    let canonical = canonicalize(&value).map_err(|e| OrlError::Canonicalize(e.to_string()))?;
262    let signing = SigningKey::from_bytes(private_key);
263    let sig: Signature = signing.sign(canonical.as_bytes());
264    list.signature.signature = STANDARD.encode(sig.to_bytes());
265    Ok(list)
266}
267
268/* -------------------------------------------------------------------------- */
269/*  Delivery receipts                                                         */
270/* -------------------------------------------------------------------------- */
271
272#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
273pub struct DeliveryReceipt {
274    pub receipt_version: String,
275    pub packet_id: String,
276    /// `sha256:<hex>` digest of the verified packet payload, so the
277    /// receipt is bound to the actual bytes the receiver saw.
278    pub packet_hash: String,
279    pub receiver: String,
280    pub received_at: String,
281    pub signature: ReceiptSignature,
282}
283
284#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
285pub struct ReceiptSignature {
286    pub algorithm: String,
287    pub signer: String,
288    pub signature: String,
289}
290
291#[derive(Debug)]
292pub struct VerifyResult {
293    pub ok: bool,
294    pub reason: Option<String>,
295}
296
297fn sha256_hashref(bytes: &[u8]) -> String {
298    let digest = Sha256::digest(bytes);
299    let hex: String = digest.iter().map(|b| format!("{:02x}", b)).collect();
300    format!("sha256:{}", hex)
301}
302
303pub fn sign_delivery_receipt(
304    packet: &Packet,
305    receiver: &str,
306    received_at: &str,
307    private_key: &[u8; 32],
308) -> Result<DeliveryReceipt, OrlError> {
309    let canonical_packet =
310        canonicalize(&serde_json::to_value(packet).unwrap_or(serde_json::Value::Null))
311            .map_err(|e| OrlError::Canonicalize(e.to_string()))?;
312    let packet_hash = sha256_hashref(canonical_packet.as_bytes());
313    let mut draft = DeliveryReceipt {
314        receipt_version: "1".into(),
315        packet_id: packet.packet_id.clone(),
316        packet_hash,
317        receiver: receiver.into(),
318        received_at: received_at.into(),
319        signature: ReceiptSignature {
320            algorithm: "ed25519".into(),
321            signer: receiver.into(),
322            signature: String::new(),
323        },
324    };
325    let mut sig_value =
326        serde_json::to_value(&draft).map_err(|e| OrlError::Canonicalize(e.to_string()))?;
327    if let serde_json::Value::Object(map) = &mut sig_value {
328        map.remove("signature");
329    }
330    let sig_canonical =
331        canonicalize(&sig_value).map_err(|e| OrlError::Canonicalize(e.to_string()))?;
332    let signing = SigningKey::from_bytes(private_key);
333    let sig: Signature = signing.sign(sig_canonical.as_bytes());
334    draft.signature.signature = STANDARD.encode(sig.to_bytes());
335    Ok(draft)
336}
337
338pub fn verify_delivery_receipt(
339    receipt: &DeliveryReceipt,
340    packet: &Packet,
341    receiver_public_key: &[u8; 32],
342) -> VerifyResult {
343    if receipt.receipt_version != "1" {
344        return reject(format!(
345            "receipt_version {} unsupported",
346            receipt.receipt_version
347        ));
348    }
349    if receipt.packet_id != packet.packet_id {
350        return reject("packet_id mismatch".into());
351    }
352    let canonical_packet = match canonicalize(&serde_json::to_value(packet).unwrap_or_default()) {
353        Ok(c) => c,
354        Err(e) => return reject(format!("canonicalize: {}", e)),
355    };
356    let expected = sha256_hashref(canonical_packet.as_bytes());
357    if expected != receipt.packet_hash {
358        return reject("packet_hash mismatch".into());
359    }
360    if receipt.signature.signer != receipt.receiver {
361        return reject("receipt signer != receiver".into());
362    }
363    let mut sig_value = match serde_json::to_value(receipt) {
364        Ok(v) => v,
365        Err(e) => return reject(format!("serde: {}", e)),
366    };
367    if let serde_json::Value::Object(map) = &mut sig_value {
368        map.remove("signature");
369    }
370    let sig_canonical = match canonicalize(&sig_value) {
371        Ok(c) => c,
372        Err(e) => return reject(format!("canonicalize: {}", e)),
373    };
374    let sig_bytes = match STANDARD.decode(&receipt.signature.signature) {
375        Ok(b) => b,
376        Err(e) => return reject(format!("signature base64: {}", e)),
377    };
378    let sig = match Signature::from_slice(&sig_bytes) {
379        Ok(s) => s,
380        Err(e) => return reject(format!("signature parse: {}", e)),
381    };
382    let vk = match VerifyingKey::from_bytes(receiver_public_key) {
383        Ok(v) => v,
384        Err(e) => return reject(format!("verifying key: {}", e)),
385    };
386    if vk.verify(sig_canonical.as_bytes(), &sig).is_err() {
387        return reject("receipt signature did not verify".into());
388    }
389    VerifyResult {
390        ok: true,
391        reason: None,
392    }
393}
394
395/* -------------------------------------------------------------------------- */
396/*  Proof of forwarding                                                       */
397/* -------------------------------------------------------------------------- */
398
399#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
400pub struct ProofOfForwarding {
401    pub proof_version: String,
402    pub packet_id: String,
403    pub packet_hash: String,
404    pub relay: String,
405    pub forwarded_at: String,
406    pub hop_count: u32,
407    pub signature: ReceiptSignature,
408}
409
410pub fn sign_proof_of_forwarding(
411    packet: &Packet,
412    relay: &str,
413    forwarded_at: &str,
414    hop_count: u32,
415    private_key: &[u8; 32],
416) -> Result<ProofOfForwarding, OrlError> {
417    let canonical_packet =
418        canonicalize(&serde_json::to_value(packet).unwrap_or(serde_json::Value::Null))
419            .map_err(|e| OrlError::Canonicalize(e.to_string()))?;
420    let packet_hash = sha256_hashref(canonical_packet.as_bytes());
421    let mut draft = ProofOfForwarding {
422        proof_version: "1".into(),
423        packet_id: packet.packet_id.clone(),
424        packet_hash,
425        relay: relay.into(),
426        forwarded_at: forwarded_at.into(),
427        hop_count,
428        signature: ReceiptSignature {
429            algorithm: "ed25519".into(),
430            signer: relay.into(),
431            signature: String::new(),
432        },
433    };
434    let mut sig_value =
435        serde_json::to_value(&draft).map_err(|e| OrlError::Canonicalize(e.to_string()))?;
436    if let serde_json::Value::Object(map) = &mut sig_value {
437        map.remove("signature");
438    }
439    let sig_canonical =
440        canonicalize(&sig_value).map_err(|e| OrlError::Canonicalize(e.to_string()))?;
441    let signing = SigningKey::from_bytes(private_key);
442    let sig: Signature = signing.sign(sig_canonical.as_bytes());
443    draft.signature.signature = STANDARD.encode(sig.to_bytes());
444    Ok(draft)
445}
446
447pub fn verify_proof_of_forwarding(
448    proof: &ProofOfForwarding,
449    packet: &Packet,
450    relay_public_key: &[u8; 32],
451) -> VerifyResult {
452    if proof.proof_version != "1" {
453        return reject(format!("proof_version {} unsupported", proof.proof_version));
454    }
455    if proof.packet_id != packet.packet_id {
456        return reject("packet_id mismatch".into());
457    }
458    let canonical_packet = match canonicalize(&serde_json::to_value(packet).unwrap_or_default()) {
459        Ok(c) => c,
460        Err(e) => return reject(format!("canonicalize: {}", e)),
461    };
462    let expected = sha256_hashref(canonical_packet.as_bytes());
463    if expected != proof.packet_hash {
464        return reject("packet_hash mismatch".into());
465    }
466    if proof.signature.signer != proof.relay {
467        return reject("proof signer != relay".into());
468    }
469    let mut sig_value = match serde_json::to_value(proof) {
470        Ok(v) => v,
471        Err(e) => return reject(format!("serde: {}", e)),
472    };
473    if let serde_json::Value::Object(map) = &mut sig_value {
474        map.remove("signature");
475    }
476    let sig_canonical = match canonicalize(&sig_value) {
477        Ok(c) => c,
478        Err(e) => return reject(format!("canonicalize: {}", e)),
479    };
480    let sig_bytes = match STANDARD.decode(&proof.signature.signature) {
481        Ok(b) => b,
482        Err(e) => return reject(format!("signature base64: {}", e)),
483    };
484    let sig = match Signature::from_slice(&sig_bytes) {
485        Ok(s) => s,
486        Err(e) => return reject(format!("signature parse: {}", e)),
487    };
488    let vk = match VerifyingKey::from_bytes(relay_public_key) {
489        Ok(v) => v,
490        Err(e) => return reject(format!("verifying key: {}", e)),
491    };
492    if vk.verify(sig_canonical.as_bytes(), &sig).is_err() {
493        return reject("forwarding signature did not verify".into());
494    }
495    VerifyResult {
496        ok: true,
497        reason: None,
498    }
499}
500
501fn reject(reason: String) -> VerifyResult {
502    VerifyResult {
503        ok: false,
504        reason: Some(reason),
505    }
506}
507
508/* -------------------------------------------------------------------------- */
509/*  Tests                                                                      */
510/* -------------------------------------------------------------------------- */
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use crate::crypto::Ed25519Signer;
516    use crate::packet::{sign_packet, SignPacketArgs};
517    use rand::rngs::OsRng;
518    use rand::RngCore;
519
520    fn fresh_seed() -> [u8; 32] {
521        let mut seed = [0u8; 32];
522        OsRng.fill_bytes(&mut seed);
523        seed
524    }
525
526    fn fixture_packet(packet_id: &str, expires_at: Option<&str>, created_at: &str) -> Packet {
527        let signer_seed = fresh_seed();
528        sign_packet(SignPacketArgs {
529            packet_id: packet_id.into(),
530            source: "tf:actor:agent:example.com/x".into(),
531            destination: "tf:actor:service:example.com/d".into(),
532            priority: "P3".into(),
533            payload: b"hi",
534            encoding: None,
535            compression: None,
536            emergency: false,
537            expires_at: expires_at.map(str::to_string),
538            ttl_hops: None,
539            route_constraints: None,
540            session_ref: None,
541            private_key: signer_seed,
542            signer: "tf:actor:agent:example.com/x".into(),
543            created_at: Some(created_at.into()),
544        })
545        .expect("sign")
546    }
547
548    #[test]
549    fn packet_receiver_accepts_then_rejects_replay() {
550        let mut recv = PacketReceiver::new(None);
551        let p = fixture_packet("pkt-A", None, "2026-04-24T12:00:00Z");
552        assert_eq!(
553            recv.observe(&p, "2026-04-24T13:00:00Z"),
554            PacketReceiverDecision::Accept
555        );
556        assert!(matches!(
557            recv.observe(&p, "2026-04-24T13:00:00Z"),
558            PacketReceiverDecision::Reject {
559                reason: PacketRejectReason::Replay
560            }
561        ));
562    }
563
564    #[test]
565    fn packet_receiver_rejects_expired() {
566        let mut recv = PacketReceiver::new(None);
567        let p = fixture_packet(
568            "pkt-old",
569            Some("2026-04-23T00:00:00Z"),
570            "2026-04-22T00:00:00Z",
571        );
572        assert!(matches!(
573            recv.observe(&p, "2026-04-24T12:00:00Z"),
574            PacketReceiverDecision::Reject {
575                reason: PacketRejectReason::Expired
576            }
577        ));
578    }
579
580    #[test]
581    fn packet_receiver_rejects_future_dated() {
582        let mut recv = PacketReceiver::new(None);
583        let p = fixture_packet("pkt-future", None, "2099-04-24T12:00:00Z");
584        assert!(matches!(
585            recv.observe(&p, "2026-04-24T12:00:00Z"),
586            PacketReceiverDecision::Reject {
587                reason: PacketRejectReason::FutureDated
588            }
589        ));
590    }
591
592    #[test]
593    fn packet_receiver_lru_evicts_oldest() {
594        let mut recv = PacketReceiver::new(Some(2));
595        for i in 0..3 {
596            let p = fixture_packet(&format!("pkt-{}", i), None, "2026-04-24T11:00:00Z");
597            assert_eq!(
598                recv.observe(&p, "2026-04-24T12:00:00Z"),
599                PacketReceiverDecision::Accept
600            );
601        }
602        assert_eq!(recv.size(), 2);
603    }
604
605    #[test]
606    fn orl_runtime_load_and_lookup() {
607        let issuer = Ed25519Signer::from_bytes(&fresh_seed());
608        let issuer_pub = issuer.public_key_bytes();
609        let issuer_priv = {
610            // Need raw seed bytes for sign_offline_revocation_list. Use
611            // a known seed for deterministic test; we keep the seed
612            // around since we sign with it directly below.
613            let seed = fresh_seed();
614            seed
615        };
616        let issuer_signer = Ed25519Signer::from_bytes(&issuer_priv);
617        let issuer_pub2 = issuer_signer.public_key_bytes();
618        let draft = OfflineRevocationList {
619            list_version: OfflineRevocationList_ListVersion::V1,
620            trust_domain: "example.com".into(),
621            issued_at: "2026-04-24T00:00:00Z".into(),
622            valid_until: "2026-04-30T00:00:00Z".into(),
623            issuer: "tf:actor:service:example.com/tf-daemon".into(),
624            revoked: vec![
625                RevokedEntry {
626                    kind: RevokedEntry_Kind::Actor,
627                    id: "tf:actor:agent:example.com/bad".into(),
628                    reason: Some("compromised".into()),
629                    revoked_at: None,
630                },
631                RevokedEntry {
632                    kind: RevokedEntry_Kind::Key,
633                    id: "kid-42".into(),
634                    reason: None,
635                    revoked_at: None,
636                },
637            ],
638            signature: crate::generated::common::SignatureEnvelope {
639                algorithm: "ed25519".to_string(),
640                signer: "tf:actor:service:example.com/tf-daemon".into(),
641                signature: String::new(),
642                hash_alg: None,
643                alt_algorithm: None,
644                alt_signature: None,
645            },
646        };
647        let _ = issuer_pub; // suppress warning if unused
648        let signed = sign_offline_revocation_list(draft, &issuer_priv).expect("sign");
649        let runtime = OfflineRevocationListRuntime::load(
650            signed.clone(),
651            &issuer_pub2,
652            "2026-04-25T00:00:00Z",
653        )
654        .expect("load");
655        assert!(runtime
656            .is_revoked(&RevokedEntry_Kind::Actor, "tf:actor:agent:example.com/bad")
657            .is_some());
658        assert!(runtime
659            .is_revoked(&RevokedEntry_Kind::Key, "kid-42")
660            .is_some());
661        assert!(runtime
662            .is_revoked(&RevokedEntry_Kind::Actor, "tf:actor:agent:example.com/ok")
663            .is_none());
664    }
665
666    #[test]
667    fn orl_runtime_rejects_expired() {
668        let issuer_priv = fresh_seed();
669        let issuer_pub = Ed25519Signer::from_bytes(&issuer_priv).public_key_bytes();
670        let draft = OfflineRevocationList {
671            list_version: OfflineRevocationList_ListVersion::V1,
672            trust_domain: "example.com".into(),
673            issued_at: "2026-04-24T00:00:00Z".into(),
674            valid_until: "2026-04-30T00:00:00Z".into(),
675            issuer: "tf:actor:service:example.com/tf-daemon".into(),
676            revoked: Vec::new(),
677            signature: crate::generated::common::SignatureEnvelope {
678                algorithm: "ed25519".to_string(),
679                signer: "tf:actor:service:example.com/tf-daemon".into(),
680                signature: String::new(),
681                hash_alg: None,
682                alt_algorithm: None,
683                alt_signature: None,
684            },
685        };
686        let signed = sign_offline_revocation_list(draft, &issuer_priv).expect("sign");
687        let r = OfflineRevocationListRuntime::load(signed, &issuer_pub, "2026-05-15T00:00:00Z");
688        assert!(matches!(r, Err(OrlError::Expired(_))));
689    }
690
691    #[test]
692    fn orl_runtime_rejects_forged_signature() {
693        let issuer_priv = fresh_seed();
694        let other_pub = Ed25519Signer::from_bytes(&fresh_seed()).public_key_bytes();
695        let draft = OfflineRevocationList {
696            list_version: OfflineRevocationList_ListVersion::V1,
697            trust_domain: "example.com".into(),
698            issued_at: "2026-04-24T00:00:00Z".into(),
699            valid_until: "2026-04-30T00:00:00Z".into(),
700            issuer: "tf:actor:service:example.com/tf-daemon".into(),
701            revoked: Vec::new(),
702            signature: crate::generated::common::SignatureEnvelope {
703                algorithm: "ed25519".to_string(),
704                signer: "tf:actor:service:example.com/tf-daemon".into(),
705                signature: String::new(),
706                hash_alg: None,
707                alt_algorithm: None,
708                alt_signature: None,
709            },
710        };
711        let signed = sign_offline_revocation_list(draft, &issuer_priv).expect("sign");
712        let r = OfflineRevocationListRuntime::load(signed, &other_pub, "2026-04-25T00:00:00Z");
713        assert!(matches!(r, Err(OrlError::BadSignature)));
714    }
715
716    #[test]
717    fn delivery_receipt_round_trip() {
718        let receiver_priv = fresh_seed();
719        let receiver_pub = Ed25519Signer::from_bytes(&receiver_priv).public_key_bytes();
720        let p = fixture_packet("pkt-deliver-1", None, "2026-04-24T12:00:00Z");
721        let receipt = sign_delivery_receipt(
722            &p,
723            "tf:actor:agent:example.com/receiver",
724            "2026-04-24T12:01:00Z",
725            &receiver_priv,
726        )
727        .expect("sign");
728        let v = verify_delivery_receipt(&receipt, &p, &receiver_pub);
729        assert!(v.ok);
730    }
731
732    #[test]
733    fn delivery_receipt_rejects_packet_mismatch() {
734        let receiver_priv = fresh_seed();
735        let receiver_pub = Ed25519Signer::from_bytes(&receiver_priv).public_key_bytes();
736        let p1 = fixture_packet("pkt-1", None, "2026-04-24T12:00:00Z");
737        let p2 = fixture_packet("pkt-2", None, "2026-04-24T12:00:00Z");
738        let receipt = sign_delivery_receipt(
739            &p1,
740            "tf:actor:agent:example.com/receiver",
741            "2026-04-24T12:01:00Z",
742            &receiver_priv,
743        )
744        .expect("sign");
745        let v = verify_delivery_receipt(&receipt, &p2, &receiver_pub);
746        assert!(!v.ok);
747        assert_eq!(v.reason.as_deref(), Some("packet_id mismatch"));
748    }
749
750    #[test]
751    fn proof_of_forwarding_round_trip_and_tamper() {
752        let relay_priv = fresh_seed();
753        let relay_pub = Ed25519Signer::from_bytes(&relay_priv).public_key_bytes();
754        let p = fixture_packet("pkt-relay-1", None, "2026-04-24T12:00:00Z");
755        let proof = sign_proof_of_forwarding(
756            &p,
757            "tf:actor:relay:example.com/edge",
758            "2026-04-24T12:01:00Z",
759            1,
760            &relay_priv,
761        )
762        .expect("sign");
763        let v = verify_proof_of_forwarding(&proof, &p, &relay_pub);
764        assert!(v.ok);
765        // Tamper the forwarded_at — re-canonicalised body no longer
766        // matches the signature, verifier rejects.
767        let tampered = ProofOfForwarding {
768            forwarded_at: "2027-01-01T00:00:00Z".into(),
769            ..proof.clone()
770        };
771        let v2 = verify_proof_of_forwarding(&tampered, &p, &relay_pub);
772        assert!(!v2.ok);
773    }
774}