Skip to main content

tf_core_no_std/
orl.rs

1//! Offline revocation list (TF-0011 §6 / TF-0012 §3) — no_std edition.
2//!
3//! The std side (`tf-types::constrained::OfflineRevocationListRuntime`)
4//! ingests a JSON-or-CBOR document and builds a `HashMap`. On a
5//! constrained device we cannot afford either the parser or the heap,
6//! so K1 defines a compact length-prefixed binary format the gateway
7//! pre-bakes for us. The format is:
8//!
9//! ```text
10//! version: u8                  // = 1
11//! issuer_len: u32 BE
12//! issuer: bytes
13//! issued_at_len: u32 BE        // ISO-8601, lex-orderable
14//! issued_at: bytes
15//! valid_until_len: u32 BE
16//! valid_until: bytes
17//! entry_count: u32 BE
18//! repeat entry_count times:
19//!     kind: u8                 // 1=actor, 2=instance, 3=capability,
20//!                              //   4=delegation, 5=key
21//!     id_len: u32 BE
22//!     id: bytes
23//! signature: 64 bytes (ed25519 over SHA-256(version || …entries))
24//! ```
25//!
26//! The signature covers everything before the signature itself — i.e.
27//! a SHA-256 of the entire prefix bytes. That makes verification a
28//! single-pass streaming check without any allocations.
29
30use core::convert::TryInto;
31
32use ed25519_compact::{PublicKey, Signature};
33use sha2::{Digest, Sha256};
34
35#[cfg(not(feature = "alloc"))]
36use heapless::FnvIndexMap;
37#[cfg(not(feature = "alloc"))]
38use heapless::String as HString;
39
40#[cfg(feature = "alloc")]
41use alloc::collections::BTreeMap;
42#[cfg(feature = "alloc")]
43use alloc::string::String;
44
45const ORL_VERSION: u8 = 1;
46const SIG_LEN: usize = 64;
47
48/// Capacity of the no_alloc map. 256 entries is sufficient for an
49/// edge-relay's local cache (TF-0012 §3 sizing guidance).
50pub const NO_ALLOC_CAPACITY: usize = 256;
51/// Maximum length of `kind:id` index keys in the no_alloc map.
52pub const NO_ALLOC_KEY_CAP: usize = 128;
53
54/// Mirrors `tf-types::generated::offline_revocation_list::RevokedEntry_Kind`.
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
56pub enum RevokedKind {
57    Actor,
58    Instance,
59    Capability,
60    Delegation,
61    Key,
62}
63
64impl RevokedKind {
65    fn from_u8(b: u8) -> Option<Self> {
66        match b {
67            1 => Some(RevokedKind::Actor),
68            2 => Some(RevokedKind::Instance),
69            3 => Some(RevokedKind::Capability),
70            4 => Some(RevokedKind::Delegation),
71            5 => Some(RevokedKind::Key),
72            _ => None,
73        }
74    }
75    pub fn to_u8(self) -> u8 {
76        match self {
77            RevokedKind::Actor => 1,
78            RevokedKind::Instance => 2,
79            RevokedKind::Capability => 3,
80            RevokedKind::Delegation => 4,
81            RevokedKind::Key => 5,
82        }
83    }
84    fn as_str(self) -> &'static str {
85        match self {
86            RevokedKind::Actor => "actor",
87            RevokedKind::Instance => "instance",
88            RevokedKind::Capability => "capability",
89            RevokedKind::Delegation => "delegation",
90            RevokedKind::Key => "key",
91        }
92    }
93}
94
95/// One revoked entry retained in the runtime index.
96#[derive(Clone, Debug)]
97pub struct RevokedEntry {
98    pub kind: RevokedKind,
99}
100
101/// Errors from `OfflineRevocationListChecker::new`.
102#[derive(Clone, Copy, Debug, PartialEq, Eq)]
103pub enum OrlError {
104    /// `version` byte is not 1.
105    UnsupportedVersion,
106    /// Buffer ended before all expected bytes were read.
107    Truncated,
108    /// `valid_until` < `now` (lexical compare on ISO-8601).
109    Expired,
110    /// `issued_at` > `now`.
111    FutureDated,
112    /// Length prefix exceeded the remaining buffer.
113    BadLength,
114    /// `kind` byte was not in `1..=5`.
115    BadKind,
116    /// Signature failed to verify.
117    BadSignature,
118    /// Public key failed to parse.
119    BadPublicKey,
120    /// No-alloc capacity overflow.
121    CapacityExceeded,
122    /// Index key (kind:id) exceeded the no_alloc cap.
123    KeyTooLarge,
124}
125
126/// The verified ORL runtime. Internally backed by either a heapless
127/// `FnvIndexMap` (no_alloc) or a `BTreeMap` (with `alloc`).
128#[derive(Debug)]
129pub struct OfflineRevocationListChecker {
130    #[cfg(feature = "alloc")]
131    index: BTreeMap<String, RevokedEntry>,
132    #[cfg(not(feature = "alloc"))]
133    index: FnvIndexMap<HString<NO_ALLOC_KEY_CAP>, RevokedEntry, NO_ALLOC_CAPACITY>,
134}
135
136impl OfflineRevocationListChecker {
137    /// Parse, signature-verify, and index an ORL byte buffer.
138    pub fn new(list_bytes: &[u8], issuer_pub: &[u8; 32], now: &str) -> Result<Self, OrlError> {
139        let mut cur = Cursor::new(list_bytes);
140        if cur.read_u8()? != ORL_VERSION {
141            return Err(OrlError::UnsupportedVersion);
142        }
143        let _issuer = cur.read_lp()?;
144        let issued_at = cur.read_lp()?;
145        let valid_until = cur.read_lp()?;
146        if !str_le(valid_until, now.as_bytes()).inverted() {
147            // valid_until < now → expired.
148            return Err(OrlError::Expired);
149        }
150        if str_lt(now.as_bytes(), issued_at) {
151            return Err(OrlError::FutureDated);
152        }
153        let entry_count = cur.read_u32()? as usize;
154        let entries_start = cur.pos;
155
156        // Slot 1: collect into the index.
157        #[cfg(feature = "alloc")]
158        let mut index: BTreeMap<String, RevokedEntry> = BTreeMap::new();
159        #[cfg(not(feature = "alloc"))]
160        let mut index: FnvIndexMap<
161            HString<NO_ALLOC_KEY_CAP>,
162            RevokedEntry,
163            NO_ALLOC_CAPACITY,
164        > = FnvIndexMap::new();
165
166        for _ in 0..entry_count {
167            let kind_byte = cur.read_u8()?;
168            let kind = RevokedKind::from_u8(kind_byte).ok_or(OrlError::BadKind)?;
169            let id = cur.read_lp()?;
170            let key = make_key(kind, id)?;
171            let entry = RevokedEntry { kind };
172            #[cfg(feature = "alloc")]
173            {
174                index.insert(key, entry);
175            }
176            #[cfg(not(feature = "alloc"))]
177            {
178                if index.insert(key, entry).is_err() {
179                    return Err(OrlError::CapacityExceeded);
180                }
181            }
182        }
183        let body_end = cur.pos;
184        // Signature follows; must be exactly SIG_LEN bytes.
185        if list_bytes.len() != body_end + SIG_LEN {
186            return Err(OrlError::Truncated);
187        }
188        let sig_bytes: &[u8; 64] = list_bytes[body_end..]
189            .try_into()
190            .map_err(|_| OrlError::Truncated)?;
191
192        // Hash the prefix (everything up to the signature).
193        let mut h = Sha256::new();
194        h.update(&list_bytes[..body_end]);
195        let digest = h.finalize();
196        let mut digest_bytes = [0u8; 32];
197        digest_bytes.copy_from_slice(&digest);
198
199        let sig = Signature::from_slice(sig_bytes).map_err(|_| OrlError::BadSignature)?;
200        let pk = PublicKey::from_slice(issuer_pub).map_err(|_| OrlError::BadPublicKey)?;
201        pk.verify(digest_bytes, &sig)
202            .map_err(|_| OrlError::BadSignature)?;
203
204        // Sanity: re-hash should match prefix bytes; consume `entries_start`
205        // to avoid the unused-binding lint while keeping the variable in
206        // case future callers want offset-into-buffer diagnostics.
207        let _ = entries_start;
208
209        Ok(OfflineRevocationListChecker { index })
210    }
211
212    /// `true` if `(kind, id)` appears in the list.
213    pub fn is_revoked(&self, kind: RevokedKind, id: &str) -> bool {
214        let key = match make_key(kind, id.as_bytes()) {
215            Ok(k) => k,
216            Err(_) => return false,
217        };
218        #[cfg(feature = "alloc")]
219        {
220            self.index.contains_key(&key)
221        }
222        #[cfg(not(feature = "alloc"))]
223        {
224            self.index.contains_key(&key)
225        }
226    }
227
228    /// Number of revocations in the index.
229    pub fn len(&self) -> usize {
230        self.index.len()
231    }
232
233    pub fn is_empty(&self) -> bool {
234        self.index.is_empty()
235    }
236}
237
238/// Build the `kind:id` key in the format used by both index variants.
239#[cfg(feature = "alloc")]
240fn make_key(kind: RevokedKind, id: &[u8]) -> Result<String, OrlError> {
241    let mut s = String::with_capacity(kind.as_str().len() + 1 + id.len());
242    s.push_str(kind.as_str());
243    s.push(':');
244    s.push_str(core::str::from_utf8(id).map_err(|_| OrlError::KeyTooLarge)?);
245    Ok(s)
246}
247
248#[cfg(not(feature = "alloc"))]
249fn make_key(kind: RevokedKind, id: &[u8]) -> Result<HString<NO_ALLOC_KEY_CAP>, OrlError> {
250    let id_str = core::str::from_utf8(id).map_err(|_| OrlError::KeyTooLarge)?;
251    let mut s: HString<NO_ALLOC_KEY_CAP> = HString::new();
252    s.push_str(kind.as_str())
253        .map_err(|_| OrlError::KeyTooLarge)?;
254    s.push(':').map_err(|_| OrlError::KeyTooLarge)?;
255    s.push_str(id_str).map_err(|_| OrlError::KeyTooLarge)?;
256    Ok(s)
257}
258
259/* ---------------------------- byte cursor ---------------------------- */
260
261struct Cursor<'a> {
262    buf: &'a [u8],
263    pos: usize,
264}
265
266impl<'a> Cursor<'a> {
267    fn new(buf: &'a [u8]) -> Self {
268        Cursor { buf, pos: 0 }
269    }
270    fn read_u8(&mut self) -> Result<u8, OrlError> {
271        let b = *self.buf.get(self.pos).ok_or(OrlError::Truncated)?;
272        self.pos += 1;
273        Ok(b)
274    }
275    fn read_u32(&mut self) -> Result<u32, OrlError> {
276        if self.pos + 4 > self.buf.len() {
277            return Err(OrlError::Truncated);
278        }
279        let bytes: [u8; 4] = self.buf[self.pos..self.pos + 4]
280            .try_into()
281            .map_err(|_| OrlError::Truncated)?;
282        self.pos += 4;
283        Ok(u32::from_be_bytes(bytes))
284    }
285    fn read_lp(&mut self) -> Result<&'a [u8], OrlError> {
286        let len = self.read_u32()? as usize;
287        if self.pos + len > self.buf.len() {
288            return Err(OrlError::BadLength);
289        }
290        let out = &self.buf[self.pos..self.pos + len];
291        self.pos += len;
292        Ok(out)
293    }
294}
295
296/* ----------------------------- helpers ------------------------------- */
297
298#[derive(Clone, Copy)]
299struct CmpResult(bool);
300impl CmpResult {
301    fn inverted(self) -> bool {
302        !self.0
303    }
304}
305
306/// `a <= b` (byte-lexicographic). Equivalent to ISO-8601 lex compare.
307fn str_le(a: &[u8], b: &[u8]) -> CmpResult {
308    CmpResult(a <= b)
309}
310fn str_lt(a: &[u8], b: &[u8]) -> bool {
311    a < b
312}
313
314/* ------------------------------- tests ------------------------------- */
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use ed25519_compact::{KeyPair, Seed};
320
321    /// Build an ORL byte buffer signed with the given seed.
322    fn build_orl(
323        seed: &Seed,
324        issuer: &str,
325        issued_at: &str,
326        valid_until: &str,
327        entries: &[(RevokedKind, &str)],
328    ) -> heapless::Vec<u8, 4096> {
329        let mut buf: heapless::Vec<u8, 4096> = heapless::Vec::new();
330        buf.push(ORL_VERSION).unwrap();
331        write_lp(&mut buf, issuer.as_bytes());
332        write_lp(&mut buf, issued_at.as_bytes());
333        write_lp(&mut buf, valid_until.as_bytes());
334        let count = entries.len() as u32;
335        buf.extend_from_slice(&count.to_be_bytes()).unwrap();
336        for (k, id) in entries {
337            buf.push(k.to_u8()).unwrap();
338            write_lp(&mut buf, id.as_bytes());
339        }
340        // Sign the prefix.
341        let mut h = Sha256::new();
342        h.update(&buf);
343        let mut digest = [0u8; 32];
344        digest.copy_from_slice(&h.finalize());
345        let kp = KeyPair::from_seed(*seed);
346        let sig = kp.sk.sign(digest, None);
347        buf.extend_from_slice(sig.as_ref()).unwrap();
348        buf
349    }
350
351    fn write_lp(buf: &mut heapless::Vec<u8, 4096>, data: &[u8]) {
352        let len = data.len() as u32;
353        buf.extend_from_slice(&len.to_be_bytes()).unwrap();
354        buf.extend_from_slice(data).unwrap();
355    }
356
357    #[test]
358    fn load_and_lookup() {
359        let seed = Seed::from_slice(&[9u8; 32]).unwrap();
360        let kp = KeyPair::from_seed(seed);
361        let pk: [u8; 32] = kp.pk.as_ref().try_into().unwrap();
362        let bytes = build_orl(
363            &seed,
364            "tf:actor:authority:example.com/root",
365            "2026-01-01T00:00:00Z",
366            "2099-01-01T00:00:00Z",
367            &[
368                (RevokedKind::Key, "tf:key:1234"),
369                (RevokedKind::Actor, "tf:actor:agent:example.com/bad"),
370            ],
371        );
372        let orl =
373            OfflineRevocationListChecker::new(&bytes, &pk, "2026-04-25T00:00:00Z").expect("load");
374        assert_eq!(orl.len(), 2);
375        assert!(orl.is_revoked(RevokedKind::Key, "tf:key:1234"));
376        assert!(orl.is_revoked(RevokedKind::Actor, "tf:actor:agent:example.com/bad"));
377        assert!(!orl.is_revoked(RevokedKind::Capability, "tf:cap:other"));
378    }
379
380    #[test]
381    fn rejects_expired() {
382        let seed = Seed::from_slice(&[9u8; 32]).unwrap();
383        let kp = KeyPair::from_seed(seed);
384        let pk: [u8; 32] = kp.pk.as_ref().try_into().unwrap();
385        let bytes = build_orl(
386            &seed,
387            "tf:actor:authority:example.com/root",
388            "2026-01-01T00:00:00Z",
389            "2026-04-01T00:00:00Z",
390            &[],
391        );
392        let r = OfflineRevocationListChecker::new(&bytes, &pk, "2026-04-25T00:00:00Z");
393        assert_eq!(r.err(), Some(OrlError::Expired));
394    }
395
396    #[test]
397    fn rejects_bad_signature() {
398        let seed = Seed::from_slice(&[9u8; 32]).unwrap();
399        let other = Seed::from_slice(&[2u8; 32]).unwrap();
400        let other_kp = KeyPair::from_seed(other);
401        let other_pk: [u8; 32] = other_kp.pk.as_ref().try_into().unwrap();
402        let bytes = build_orl(
403            &seed,
404            "tf:actor:authority:example.com/root",
405            "2026-01-01T00:00:00Z",
406            "2099-01-01T00:00:00Z",
407            &[(RevokedKind::Actor, "tf:actor:agent:example.com/bad")],
408        );
409        let r = OfflineRevocationListChecker::new(&bytes, &other_pk, "2026-04-25T00:00:00Z");
410        assert_eq!(r.err(), Some(OrlError::BadSignature));
411    }
412}