Skip to main content

darkbio_crypto/cwt/
mod.rs

1// crypto-rs: cryptography primitives and wrappers
2// Copyright 2026 Dark Bio AG. All rights reserved.
3//
4// Use of this source code is governed by a BSD-style
5// license that can be found in the LICENSE file.
6
7//! CBOR Web Tokens on top of COSE Sign1.
8//!
9//! <https://datatracker.ietf.org/doc/html/rfc8392>
10//!
11//! Tokens carry a set of claims encoded as a CBOR map. Standard claims are
12//! provided as embeddable single-field structs ([`claims::Issuer`],
13//! [`claims::Subject`], etc.) that can be composed into application-specific
14//! token types.
15//!
16//! [`verify`] checks the signature against the supplied key and, when requested,
17//! the `nbf` and `exp` time bounds. Applications must establish trust in that key,
18//! check issuer and audience claims, and apply their own attestation policy.
19//! EAT claim relationships and proof of possession of a `cnf` key are not
20//! automatically checked.
21//!
22//! # Example
23//!
24//! ```
25//! use darkbio_crypto::cbor::Cbor;
26//! use darkbio_crypto::cwt::{self, claims};
27//! use darkbio_crypto::xdsa;
28//!
29//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
30//! #[derive(Cbor)]
31//! struct DeviceCert {
32//!     #[cbor(embed)]
33//!     sub: claims::Subject,
34//!     #[cbor(embed)]
35//!     exp: claims::Expiration,
36//!     #[cbor(embed)]
37//!     nbf: claims::NotBefore,
38//!     #[cbor(embed)]
39//!     cnf: claims::Confirm<xdsa::PublicKey>,
40//!     #[cbor(key = 256)]
41//!     ueid: Vec<u8>,
42//! }
43//!
44//! let issuer = xdsa::SecretKey::generate();
45//! let device = xdsa::SecretKey::generate();
46//! let now = 1_700_000_000;
47//!
48//! // Example RAND UEID from the generated identity: type 0x01 and 16 identifier bytes.
49//! // Provision it once and retain it for the device's lifetime, even if keys change.
50//! let ueid = [&[0x01][..], &device.fingerprint().to_bytes()[..16]].concat();
51//!
52//! let cert = DeviceCert {
53//!     sub: claims::Subject { sub: "ark-0001".into() },
54//!     exp: claims::Expiration { exp: now + 3600 },
55//!     nbf: claims::NotBefore { nbf: now },
56//!     cnf: claims::Confirm::new(device.public_key()),
57//!     ueid,
58//! };
59//! let token = cwt::issue(&cert, &issuer, b"device-cert")?;
60//!
61//! let verified: DeviceCert =
62//!     cwt::verify(&token, &issuer.public_key(), b"device-cert", Some(now + 60))?;
63//! assert_eq!(verified.sub.sub, "ark-0001");
64//! assert_eq!(verified.cnf.key().fingerprint(), device.fingerprint());
65//!
66//! // Outside the validity window the token is rejected
67//! let late = cwt::verify::<DeviceCert>(&token, &issuer.public_key(), b"device-cert", Some(now + 7200));
68//! assert!(late.is_err());
69//! # Ok(())
70//! # }
71//! ```
72
73pub mod claims;
74
75use crate::cbor::{self, Decode, Encode, Raw};
76use crate::{cose, xdsa};
77
78/// Failures of the CWT operations.
79#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
80pub enum Error {
81    /// The token or its claims are not valid CBOR under this crate's rules.
82    #[error("cbor: {0}")]
83    Cbor(#[from] cbor::Error),
84    /// The [`cose::CoseSign1`] envelope was rejected, a bad signature, the
85    /// wrong key or a malformed header.
86    #[error("cose: {0}")]
87    Cose(#[from] cose::Error),
88    /// [`verify`] was asked to check time but the claims carry no `nbf`, key 5.
89    #[error("missing nbf claim")]
90    MissingNbf,
91    /// The claims map carries a temporal claim key twice.
92    #[error("duplicate claim key {0}")]
93    DuplicateKey(i64),
94    /// The token's `nbf` ([`claims::NotBefore`]) lies after the time of the
95    /// check.
96    #[error("token not yet valid: nbf {nbf} > now {now}")]
97    NotYetValid {
98        /// Not-before claim, seconds since the Unix epoch.
99        nbf: u64,
100        /// Time of the check, seconds since the Unix epoch.
101        now: u64,
102    },
103    /// The token's `exp` ([`claims::Expiration`]) is at or before the time of
104    /// the check.
105    #[error("token already expired: exp {exp} <= now {now}")]
106    AlreadyExpired {
107        /// Expiration claim, seconds since the Unix epoch.
108        exp: u64,
109        /// Time of the check, seconds since the Unix epoch.
110        now: u64,
111    },
112}
113
114/// Signs a set of claims as a CWT using [`cose::sign`].
115///
116/// The claims value must be a struct whose fields encode as a CBOR map
117/// (using `#[cbor(key = N)]` tags and/or embedded claim types).
118///
119/// Uses the current system time as the COSE signature timestamp.
120pub fn issue(
121    claims: &impl Encode,
122    signer: &xdsa::SecretKey,
123    domain: &[u8],
124) -> Result<Vec<u8>, Error> {
125    let claims_bytes = cbor::encode(claims)?;
126    Ok(cose::sign(Raw(claims_bytes), cbor::NULL, signer, domain)?)
127}
128
129/// Signs a set of claims as a CWT with an explicit COSE timestamp.
130///
131/// This is primarily useful for testing with deterministic timestamps.
132pub fn issue_at(
133    claims: &impl Encode,
134    signer: &xdsa::SecretKey,
135    domain: &[u8],
136    timestamp: i64,
137) -> Result<Vec<u8>, Error> {
138    let claims_bytes = cbor::encode(claims)?;
139    Ok(cose::sign_at(
140        Raw(claims_bytes),
141        cbor::NULL,
142        signer,
143        domain,
144        timestamp,
145    )?)
146}
147
148/// Verifies a CWT's COSE signature and temporal validity, then decodes
149/// the claims into T.
150///
151/// When `now` is `Some`, temporal claims are validated. The nbf claim (key 5,
152/// [`claims::NotBefore`]) must be present and `nbf <= now`, and if the exp claim
153/// (key 4, [`claims::Expiration`]) is present then `now < exp`. When `now` is
154/// `None`, temporal validation is skipped entirely.
155///
156/// The COSE signature timestamp is not checked; temporal validity comes from
157/// the CWT claims. `T` determines the accepted claim schema. Successful
158/// verification does not establish issuer trust, enforce an audience, evaluate
159/// attestation policy or EAT claim relationships, or prove possession of a
160/// [`claims::Confirm`] key. The application must perform those checks.
161pub fn verify<T: Decode>(
162    data: &[u8],
163    verifier: &xdsa::PublicKey,
164    domain: &[u8],
165    now: Option<u64>,
166) -> Result<T, Error> {
167    // Verify COSE signature (skip COSE drift check, CWT handles temporal validation)
168    let raw: Raw = cose::verify(data, cbor::NULL, verifier, domain, None)?;
169
170    // Extract and validate temporal claims if requested
171    if let Some(now) = now {
172        let (nbf, exp) = read_temporal_claims(&raw)?;
173        if now < nbf {
174            return Err(Error::NotYetValid { nbf, now });
175        }
176        if let Some(exp) = exp
177            && now >= exp
178        {
179            return Err(Error::AlreadyExpired { exp, now });
180        }
181    }
182    // Decode claims into T
183    Ok(cbor::decode(&raw.0)?)
184}
185
186/// Extracts the signer's fingerprint from a CWT without verifying
187/// the signature. The returned data is unauthenticated.
188pub fn signer(data: &[u8]) -> Result<xdsa::Fingerprint, Error> {
189    Ok(cose::signer(data)?)
190}
191
192/// Extracts and decodes claims from a CWT without verifying the signature.
193///
194/// **Warning**: This function does NOT verify the signature. The returned payload
195/// is unauthenticated and should not be trusted until verified with [`verify`].
196/// Use [`signer`] to extract the signer's fingerprint for key lookup. The single
197/// case for this method is self-signed key discovery.
198pub fn peek<T: Decode>(data: &[u8]) -> Result<T, Error> {
199    let raw: Raw = cose::peek(data)?;
200    Ok(cbor::decode(&raw.0)?)
201}
202
203/// Reads temporal claims (exp key 4, nbf key 5) from raw CBOR map bytes.
204/// Returns nbf (required) and exp (optional, None if absent).
205fn read_temporal_claims(raw: &[u8]) -> Result<(u64, Option<u64>), Error> {
206    let mut dec = cbor::Decoder::new(raw);
207    let n = dec.decode_map_header()?;
208
209    let mut nbf: Option<u64> = None;
210    let mut exp: Option<u64> = None;
211
212    for _ in 0..n {
213        let key = dec.decode_int()?;
214        match key {
215            4 => {
216                // exp (reject duplicates)
217                if exp.is_some() {
218                    return Err(Error::DuplicateKey(4));
219                }
220                exp = Some(dec.decode_uint()?);
221            }
222            5 => {
223                // nbf (reject duplicates)
224                if nbf.is_some() {
225                    return Err(Error::DuplicateKey(5));
226                }
227                nbf = Some(dec.decode_uint()?);
228            }
229            _ => {
230                // Skip unknown claim value
231                Raw::decode_cbor_notrail(&mut dec)?;
232            }
233        }
234    }
235    let nbf = nbf.ok_or(Error::MissingNbf)?;
236    Ok((nbf, exp))
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::cbor::Cbor;
243    use crate::cwt::claims;
244    use crate::cwt::claims::eat;
245
246    /// The minimal token type used by most tests.
247    #[derive(Debug, Cbor)]
248    struct SimpleCert {
249        #[cbor(embed)]
250        sub: claims::Subject,
251        #[cbor(embed)]
252        exp: Option<claims::Expiration>,
253        #[cbor(embed)]
254        nbf: claims::NotBefore,
255        #[cbor(embed)]
256        cnf: claims::Confirm<xdsa::PublicKey>,
257    }
258
259    /// A composite token type with EAT claims.
260    #[derive(Debug, Cbor)]
261    struct DeviceCert {
262        #[cbor(embed)]
263        sub: claims::Subject,
264        #[cbor(embed)]
265        exp: Option<claims::Expiration>,
266        #[cbor(embed)]
267        nbf: claims::NotBefore,
268        #[cbor(embed)]
269        cnf: claims::Confirm<xdsa::PublicKey>,
270        #[cbor(embed)]
271        ueid: eat::Ueid,
272    }
273
274    /// Token type without NotBefore, used for missing-nbf test.
275    #[derive(Debug, Cbor)]
276    struct NoNbfCert {
277        #[cbor(embed)]
278        sub: claims::Subject,
279        #[cbor(embed)]
280        cnf: claims::Confirm<xdsa::PublicKey>,
281    }
282
283    /// Token type without Expiration, used for no-expiration test.
284    #[derive(Debug, Cbor)]
285    struct NoExpCert {
286        #[cbor(embed)]
287        sub: claims::Subject,
288        #[cbor(embed)]
289        nbf: claims::NotBefore,
290        #[cbor(embed)]
291        cnf: claims::Confirm<xdsa::PublicKey>,
292    }
293
294    /// Tests the happy path: issue a token and verify it.
295    #[test]
296    fn test_issue_verify() {
297        let issuer = xdsa::SecretKey::generate();
298        let device = xdsa::SecretKey::generate();
299
300        let cert = DeviceCert {
301            sub: claims::Subject {
302                sub: "device-abc".into(),
303            },
304            exp: Some(claims::Expiration { exp: 2000000 }),
305            nbf: claims::NotBefore { nbf: 1000000 },
306            cnf: claims::Confirm::new(device.public_key()),
307            ueid: eat::Ueid {
308                ueid: b"SN-999".to_vec(),
309            },
310        };
311        let token = issue(&cert, &issuer, b"test-domain").expect("issue");
312        let got: DeviceCert =
313            verify(&token, &issuer.public_key(), b"test-domain", Some(1500000)).expect("verify");
314
315        assert_eq!(got.sub.sub, "device-abc");
316        assert_eq!(got.exp.unwrap().exp, 2000000);
317        assert_eq!(got.cnf.key().to_bytes(), device.public_key().to_bytes(),);
318        assert_eq!(got.ueid.ueid, b"SN-999");
319    }
320
321    /// Tests that now=None skips temporal validation.
322    #[test]
323    fn test_verify_skip_time() {
324        let issuer = xdsa::SecretKey::generate();
325
326        let cert = SimpleCert {
327            sub: claims::Subject { sub: "test".into() },
328            exp: None,
329            nbf: claims::NotBefore { nbf: 1000000 },
330            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
331        };
332        let token = issue(&cert, &issuer, b"test").expect("issue");
333        let got: SimpleCert =
334            verify(&token, &issuer.public_key(), b"test", None).expect("verify with None time");
335
336        assert_eq!(got.sub.sub, "test");
337    }
338
339    /// Tests rejection when now < nbf.
340    #[test]
341    fn test_verify_not_yet_valid() {
342        let issuer = xdsa::SecretKey::generate();
343
344        let cert = SimpleCert {
345            sub: claims::Subject { sub: "test".into() },
346            exp: None,
347            nbf: claims::NotBefore { nbf: 1000000 },
348            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
349        };
350        let token = issue(&cert, &issuer, b"test").expect("issue");
351        let err = verify::<SimpleCert>(&token, &issuer.public_key(), b"test", Some(500000))
352            .expect_err("should fail");
353
354        assert!(matches!(err, Error::NotYetValid { .. }));
355    }
356
357    /// Tests rejection when now > exp.
358    #[test]
359    fn test_verify_expired() {
360        let issuer = xdsa::SecretKey::generate();
361
362        let cert = SimpleCert {
363            sub: claims::Subject { sub: "test".into() },
364            exp: Some(claims::Expiration { exp: 2000000 }),
365            nbf: claims::NotBefore { nbf: 1000000 },
366            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
367        };
368        let token = issue(&cert, &issuer, b"test").expect("issue");
369        let err = verify::<SimpleCert>(&token, &issuer.public_key(), b"test", Some(3000000))
370            .expect_err("should fail");
371
372        assert!(matches!(err, Error::AlreadyExpired { .. }));
373    }
374
375    /// Tests rejection when nbf is absent and time check is on.
376    #[test]
377    fn test_verify_missing_nbf() {
378        let issuer = xdsa::SecretKey::generate();
379
380        let cert = NoNbfCert {
381            sub: claims::Subject { sub: "test".into() },
382            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
383        };
384        let token = issue(&cert, &issuer, b"test").expect("issue");
385        let err = verify::<NoNbfCert>(&token, &issuer.public_key(), b"test", Some(1000000))
386            .expect_err("should fail");
387
388        assert!(matches!(err, Error::MissingNbf));
389    }
390
391    /// Tests rejection with wrong verifier key.
392    #[test]
393    fn test_verify_wrong_key() {
394        let issuer = xdsa::SecretKey::generate();
395        let wrong = xdsa::SecretKey::generate();
396
397        let cert = SimpleCert {
398            sub: claims::Subject { sub: "test".into() },
399            exp: None,
400            nbf: claims::NotBefore { nbf: 1000000 },
401            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
402        };
403        let token = issue(&cert, &issuer, b"test").expect("issue");
404
405        assert!(verify::<SimpleCert>(&token, &wrong.public_key(), b"test", Some(1500000)).is_err());
406    }
407
408    /// Tests fingerprint extraction from a token.
409    #[test]
410    fn test_signer() {
411        let issuer = xdsa::SecretKey::generate();
412
413        let cert = SimpleCert {
414            sub: claims::Subject { sub: "test".into() },
415            exp: None,
416            nbf: claims::NotBefore { nbf: 1000000 },
417            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
418        };
419        let token = issue(&cert, &issuer, b"test").expect("issue");
420        let fp = signer(&token).expect("signer");
421
422        assert_eq!(fp, issuer.public_key().fingerprint());
423    }
424
425    /// Tests unauthenticated claims extraction.
426    #[test]
427    fn test_peek() {
428        let issuer = xdsa::SecretKey::generate();
429
430        let cert = SimpleCert {
431            sub: claims::Subject {
432                sub: "peek-test".into(),
433            },
434            exp: None,
435            nbf: claims::NotBefore { nbf: 1000000 },
436            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
437        };
438        let token = issue(&cert, &issuer, b"test").expect("issue");
439        let got: SimpleCert = peek(&token).expect("peek");
440
441        assert_eq!(got.sub.sub, "peek-test");
442    }
443
444    /// Tests rejection when the verification domain differs.
445    #[test]
446    fn test_verify_wrong_domain() {
447        let issuer = xdsa::SecretKey::generate();
448
449        let cert = SimpleCert {
450            sub: claims::Subject { sub: "test".into() },
451            exp: None,
452            nbf: claims::NotBefore { nbf: 1000000 },
453            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
454        };
455        let token = issue(&cert, &issuer, b"domain-a").expect("issue");
456
457        assert!(
458            verify::<SimpleCert>(&token, &issuer.public_key(), b"domain-b", Some(1500000)).is_err()
459        );
460    }
461
462    /// Tests that now == nbf passes and now == exp fails per RFC 8392.
463    #[test]
464    fn test_verify_boundary_exact() {
465        let issuer = xdsa::SecretKey::generate();
466
467        let cert = SimpleCert {
468            sub: claims::Subject { sub: "test".into() },
469            exp: Some(claims::Expiration { exp: 2000000 }),
470            nbf: claims::NotBefore { nbf: 1000000 },
471            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
472        };
473        let token = issue(&cert, &issuer, b"test").expect("issue");
474
475        // now == nbf should pass
476        verify::<SimpleCert>(&token, &issuer.public_key(), b"test", Some(1000000))
477            .expect("now == nbf should pass");
478
479        // now == exp should fail
480        let err = verify::<SimpleCert>(&token, &issuer.public_key(), b"test", Some(2000000))
481            .expect_err("now == exp should fail");
482        assert!(matches!(err, Error::AlreadyExpired { .. }));
483    }
484
485    /// Tests that a token without exp passes time validation.
486    #[test]
487    fn test_verify_no_expiration() {
488        let issuer = xdsa::SecretKey::generate();
489
490        let cert = NoExpCert {
491            sub: claims::Subject { sub: "test".into() },
492            nbf: claims::NotBefore { nbf: 1000000 },
493            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
494        };
495        let token = issue(&cert, &issuer, b"test").expect("issue");
496
497        // Should pass even far in the future since there's no exp
498        verify::<NoExpCert>(&token, &issuer.public_key(), b"test", Some(99999999))
499            .expect("no exp should pass");
500    }
501
502    /// Tests that duplicate temporal claim keys are rejected.
503    #[test]
504    fn test_verify_duplicate_nbf() {
505        let issuer = xdsa::SecretKey::generate();
506
507        // Manually construct a CBOR map with duplicate nbf (key 5)
508        let mut enc = cbor::Encoder::new();
509        enc.encode_map_header(3);
510        enc.encode_int(2);
511        enc.encode_text("test");
512        enc.encode_int(5);
513        enc.encode_uint(1000000);
514        enc.encode_int(5); // duplicate nbf
515        enc.encode_uint(2000000);
516
517        // Sign the raw payload via COSE (bypassing CWT's encoder)
518        let token = cose::sign(Raw(enc.finish()), cbor::NULL, &issuer, b"test").expect("sign");
519
520        // Verify should fail with DuplicateKey before claims decoding
521        let err = verify::<SimpleCert>(&token, &issuer.public_key(), b"test", Some(1500000))
522            .expect_err("should reject duplicate nbf");
523        assert!(matches!(err, Error::DuplicateKey(5)));
524    }
525
526    /// Fixture corpus generated with v0.16.0 to pin the CWT wire format.
527    const FIXTURES: &str = include_str!("testdata/v0.16.json");
528
529    /// Retrieves a hex encoded field from the v0.16 fixture corpus.
530    fn fixture(corpus: &serde_json::Value, key: &str) -> Vec<u8> {
531        hex::decode(corpus[key].as_str().unwrap()).unwrap()
532    }
533
534    /// Tests that the v0.16 fixture corpus still validates, since that was in the
535    /// first public release of the Ark, so we can't change the format anymore.
536    #[test]
537    fn test_v0_16_fixtures() {
538        let corpus: serde_json::Value = serde_json::from_str(FIXTURES).unwrap();
539
540        let xdsa_seed: [u8; 64] = fixture(&corpus, "xdsa_seed").try_into().unwrap();
541        let signer = xdsa::SecretKey::from_bytes(&xdsa_seed);
542
543        let domain = fixture(&corpus, "domain");
544        let now = corpus["now"].as_u64().unwrap();
545
546        // Verify the committed valid token and check the decoded claims
547        let valid = fixture(&corpus, "valid");
548        let got: SimpleCert = verify(&valid, &signer.public_key(), &domain, Some(now)).unwrap();
549        assert_eq!(got.sub.sub, "fixture");
550        assert_eq!(got.exp.unwrap().exp, 4102444800);
551        assert_eq!(got.nbf.nbf, 1500000000);
552        assert_eq!(got.cnf.key().to_bytes(), signer.public_key().to_bytes());
553
554        // The expired and premature tokens must keep failing temporally
555        let expired = fixture(&corpus, "expired");
556        let err = verify::<SimpleCert>(&expired, &signer.public_key(), &domain, Some(now));
557        assert!(matches!(err, Err(Error::AlreadyExpired { .. })));
558
559        let premature = fixture(&corpus, "premature");
560        let err = verify::<NoExpCert>(&premature, &signer.public_key(), &domain, Some(now));
561        assert!(matches!(err, Err(Error::NotYetValid { .. })));
562
563        // A tampered token must fail the signature check
564        let mut tampered = valid.clone();
565        *tampered.last_mut().unwrap() ^= 1;
566        let err = verify::<SimpleCert>(&tampered, &signer.public_key(), &domain, Some(now));
567        assert!(err.is_err());
568    }
569}