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