1use 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
48pub const NO_ALLOC_CAPACITY: usize = 256;
51pub const NO_ALLOC_KEY_CAP: usize = 128;
53
54#[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#[derive(Clone, Debug)]
97pub struct RevokedEntry {
98 pub kind: RevokedKind,
99}
100
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
103pub enum OrlError {
104 UnsupportedVersion,
106 Truncated,
108 Expired,
110 FutureDated,
112 BadLength,
114 BadKind,
116 BadSignature,
118 BadPublicKey,
120 CapacityExceeded,
122 KeyTooLarge,
124}
125
126#[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 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 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 #[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 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 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 let _ = entries_start;
208
209 Ok(OfflineRevocationListChecker { index })
210 }
211
212 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 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#[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
259struct 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#[derive(Clone, Copy)]
299struct CmpResult(bool);
300impl CmpResult {
301 fn inverted(self) -> bool {
302 !self.0
303 }
304}
305
306fn 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#[cfg(test)]
317mod tests {
318 use super::*;
319 use ed25519_compact::{KeyPair, Seed};
320
321 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 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}