darkbio-crypto 0.20.0

Cryptography primitives and wrappers
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
// crypto-rs: cryptography primitives and wrappers
// Copyright 2026 Dark Bio AG. All rights reserved.
//
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

//! CBOR Web Tokens on top of COSE Sign1.
//!
//! <https://datatracker.ietf.org/doc/html/rfc8392>
//!
//! Tokens carry a set of claims encoded as a CBOR map. Standard claims are
//! provided as embeddable single-field structs ([`claims::Issuer`],
//! [`claims::Subject`], etc.) that can be composed into application-specific
//! token types.
//!
//! [`verify`] checks the signature against the supplied key and, when requested,
//! the `nbf` and `exp` time bounds. Applications must establish trust in that key,
//! check issuer and audience claims, and apply their own attestation policy.
//! EAT claim relationships and proof of possession of a `cnf` key are not
//! automatically checked.
//!
//! # Example
//!
//! ```
//! use darkbio_crypto::cbor::Cbor;
//! use darkbio_crypto::cwt::{self, claims};
//! use darkbio_crypto::xdsa;
//!
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
//! #[derive(Cbor)]
//! struct DeviceCert {
//!     #[cbor(embed)]
//!     sub: claims::Subject,
//!     #[cbor(embed)]
//!     exp: claims::Expiration,
//!     #[cbor(embed)]
//!     nbf: claims::NotBefore,
//!     #[cbor(embed)]
//!     cnf: claims::Confirm<xdsa::PublicKey>,
//!     #[cbor(key = 256)]
//!     ueid: Vec<u8>,
//! }
//!
//! let issuer = xdsa::SecretKey::generate();
//! let device = xdsa::SecretKey::generate();
//! let now = 1_700_000_000;
//!
//! // Example RAND UEID from the generated identity: type 0x01 and 16 identifier bytes.
//! // Provision it once and retain it for the device's lifetime, even if keys change.
//! let ueid = [&[0x01][..], &device.fingerprint().to_bytes()[..16]].concat();
//!
//! let cert = DeviceCert {
//!     sub: claims::Subject { sub: "ark-0001".into() },
//!     exp: claims::Expiration { exp: now + 3600 },
//!     nbf: claims::NotBefore { nbf: now },
//!     cnf: claims::Confirm::new(device.public_key()),
//!     ueid,
//! };
//! let token = cwt::issue(&cert, &issuer, b"device-cert")?;
//!
//! let verified: DeviceCert =
//!     cwt::verify(&token, &issuer.public_key(), b"device-cert", Some(now + 60))?;
//! assert_eq!(verified.sub.sub, "ark-0001");
//! assert_eq!(verified.cnf.key().fingerprint(), device.fingerprint());
//!
//! // Outside the validity window the token is rejected
//! let late = cwt::verify::<DeviceCert>(&token, &issuer.public_key(), b"device-cert", Some(now + 7200));
//! assert!(late.is_err());
//! # Ok(())
//! # }
//! ```

pub mod claims;

use crate::cbor::{self, Decode, Encode, Raw};
use crate::{cose, xdsa};

/// Failures of the CWT operations.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum Error {
    /// The token or its claims are not valid CBOR under this crate's rules.
    #[error("cbor: {0}")]
    Cbor(#[from] cbor::Error),
    /// The [`cose::CoseSign1`] envelope was rejected, a bad signature, the
    /// wrong key or a malformed header.
    #[error("cose: {0}")]
    Cose(#[from] cose::Error),
    /// [`verify`] was asked to check time but the claims carry no `nbf`, key 5.
    #[error("missing nbf claim")]
    MissingNbf,
    /// The claims map carries a temporal claim key twice.
    #[error("duplicate claim key {0}")]
    DuplicateKey(i64),
    /// The token's `nbf` ([`claims::NotBefore`]) lies after the time of the
    /// check.
    #[error("token not yet valid: nbf {nbf} > now {now}")]
    NotYetValid {
        /// Not-before claim, seconds since the Unix epoch.
        nbf: u64,
        /// Time of the check, seconds since the Unix epoch.
        now: u64,
    },
    /// The token's `exp` ([`claims::Expiration`]) is at or before the time of
    /// the check.
    #[error("token already expired: exp {exp} <= now {now}")]
    AlreadyExpired {
        /// Expiration claim, seconds since the Unix epoch.
        exp: u64,
        /// Time of the check, seconds since the Unix epoch.
        now: u64,
    },
}

/// Signs a set of claims as a CWT using [`cose::sign`].
///
/// The claims value must be a struct whose fields encode as a CBOR map
/// (using `#[cbor(key = N)]` tags and/or embedded claim types).
///
/// Uses the current system time as the COSE signature timestamp.
pub fn issue(
    claims: &impl Encode,
    signer: &xdsa::SecretKey,
    domain: &[u8],
) -> Result<Vec<u8>, Error> {
    let claims_bytes = cbor::encode(claims)?;
    Ok(cose::sign(Raw(claims_bytes), cbor::NULL, signer, domain)?)
}

/// Signs a set of claims as a CWT with an explicit COSE timestamp.
///
/// This is primarily useful for testing with deterministic timestamps.
pub fn issue_at(
    claims: &impl Encode,
    signer: &xdsa::SecretKey,
    domain: &[u8],
    timestamp: i64,
) -> Result<Vec<u8>, Error> {
    let claims_bytes = cbor::encode(claims)?;
    Ok(cose::sign_at(
        Raw(claims_bytes),
        cbor::NULL,
        signer,
        domain,
        timestamp,
    )?)
}

/// Verifies a CWT's COSE signature and temporal validity, then decodes
/// the claims into T.
///
/// When `now` is `Some`, temporal claims are validated. The nbf claim (key 5,
/// [`claims::NotBefore`]) must be present and `nbf <= now`, and if the exp claim
/// (key 4, [`claims::Expiration`]) is present then `now < exp`. When `now` is
/// `None`, temporal validation is skipped entirely.
///
/// The COSE signature timestamp is not checked; temporal validity comes from
/// the CWT claims. `T` determines the accepted claim schema. Successful
/// verification does not establish issuer trust, enforce an audience, evaluate
/// attestation policy or EAT claim relationships, or prove possession of a
/// [`claims::Confirm`] key. The application must perform those checks.
pub fn verify<T: Decode>(
    data: &[u8],
    verifier: &xdsa::PublicKey,
    domain: &[u8],
    now: Option<u64>,
) -> Result<T, Error> {
    // Verify COSE signature (skip COSE drift check, CWT handles temporal validation)
    let raw: Raw = cose::verify(data, cbor::NULL, verifier, domain, None)?;

    // Extract and validate temporal claims if requested
    if let Some(now) = now {
        let (nbf, exp) = read_temporal_claims(&raw)?;
        if now < nbf {
            return Err(Error::NotYetValid { nbf, now });
        }
        if let Some(exp) = exp
            && now >= exp
        {
            return Err(Error::AlreadyExpired { exp, now });
        }
    }
    // Decode claims into T
    Ok(cbor::decode(&raw.0)?)
}

/// Extracts the signer's fingerprint from a CWT without verifying
/// the signature. The returned data is unauthenticated.
pub fn signer(data: &[u8]) -> Result<xdsa::Fingerprint, Error> {
    Ok(cose::signer(data)?)
}

/// Extracts and decodes claims from a CWT without verifying the signature.
///
/// **Warning**: This function does NOT verify the signature. The returned payload
/// is unauthenticated and should not be trusted until verified with [`verify`].
/// Use [`signer`] to extract the signer's fingerprint for key lookup. The single
/// case for this method is self-signed key discovery.
pub fn peek<T: Decode>(data: &[u8]) -> Result<T, Error> {
    let raw: Raw = cose::peek(data)?;
    Ok(cbor::decode(&raw.0)?)
}

/// Reads temporal claims (exp key 4, nbf key 5) from raw CBOR map bytes.
/// Returns nbf (required) and exp (optional, None if absent).
fn read_temporal_claims(raw: &[u8]) -> Result<(u64, Option<u64>), Error> {
    let mut dec = cbor::Decoder::new(raw);
    let n = dec.decode_map_header()?;

    let mut nbf: Option<u64> = None;
    let mut exp: Option<u64> = None;

    for _ in 0..n {
        let key = dec.decode_int()?;
        match key {
            4 => {
                // exp (reject duplicates)
                if exp.is_some() {
                    return Err(Error::DuplicateKey(4));
                }
                exp = Some(dec.decode_uint()?);
            }
            5 => {
                // nbf (reject duplicates)
                if nbf.is_some() {
                    return Err(Error::DuplicateKey(5));
                }
                nbf = Some(dec.decode_uint()?);
            }
            _ => {
                // Skip unknown claim value
                Raw::decode_cbor_notrail(&mut dec)?;
            }
        }
    }
    let nbf = nbf.ok_or(Error::MissingNbf)?;
    Ok((nbf, exp))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cbor::Cbor;
    use crate::cwt::claims;
    use crate::cwt::claims::eat;

    /// The minimal token type used by most tests.
    #[derive(Debug, Cbor)]
    struct SimpleCert {
        #[cbor(embed)]
        sub: claims::Subject,
        #[cbor(embed)]
        exp: Option<claims::Expiration>,
        #[cbor(embed)]
        nbf: claims::NotBefore,
        #[cbor(embed)]
        cnf: claims::Confirm<xdsa::PublicKey>,
    }

    /// A composite token type with EAT claims.
    #[derive(Debug, Cbor)]
    struct DeviceCert {
        #[cbor(embed)]
        sub: claims::Subject,
        #[cbor(embed)]
        exp: Option<claims::Expiration>,
        #[cbor(embed)]
        nbf: claims::NotBefore,
        #[cbor(embed)]
        cnf: claims::Confirm<xdsa::PublicKey>,
        #[cbor(embed)]
        ueid: eat::Ueid,
    }

    /// Token type without NotBefore, used for missing-nbf test.
    #[derive(Debug, Cbor)]
    struct NoNbfCert {
        #[cbor(embed)]
        sub: claims::Subject,
        #[cbor(embed)]
        cnf: claims::Confirm<xdsa::PublicKey>,
    }

    /// Token type without Expiration, used for no-expiration test.
    #[derive(Debug, Cbor)]
    struct NoExpCert {
        #[cbor(embed)]
        sub: claims::Subject,
        #[cbor(embed)]
        nbf: claims::NotBefore,
        #[cbor(embed)]
        cnf: claims::Confirm<xdsa::PublicKey>,
    }

    /// Tests the happy path: issue a token and verify it.
    #[test]
    fn test_issue_verify() {
        let issuer = xdsa::SecretKey::generate();
        let device = xdsa::SecretKey::generate();

        let cert = DeviceCert {
            sub: claims::Subject {
                sub: "device-abc".into(),
            },
            exp: Some(claims::Expiration { exp: 2000000 }),
            nbf: claims::NotBefore { nbf: 1000000 },
            cnf: claims::Confirm::new(device.public_key()),
            ueid: eat::Ueid {
                ueid: b"SN-999".to_vec(),
            },
        };
        let token = issue(&cert, &issuer, b"test-domain").expect("issue");
        let got: DeviceCert =
            verify(&token, &issuer.public_key(), b"test-domain", Some(1500000)).expect("verify");

        assert_eq!(got.sub.sub, "device-abc");
        assert_eq!(got.exp.unwrap().exp, 2000000);
        assert_eq!(got.cnf.key().to_bytes(), device.public_key().to_bytes(),);
        assert_eq!(got.ueid.ueid, b"SN-999");
    }

    /// Tests that now=None skips temporal validation.
    #[test]
    fn test_verify_skip_time() {
        let issuer = xdsa::SecretKey::generate();

        let cert = SimpleCert {
            sub: claims::Subject { sub: "test".into() },
            exp: None,
            nbf: claims::NotBefore { nbf: 1000000 },
            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
        };
        let token = issue(&cert, &issuer, b"test").expect("issue");
        let got: SimpleCert =
            verify(&token, &issuer.public_key(), b"test", None).expect("verify with None time");

        assert_eq!(got.sub.sub, "test");
    }

    /// Tests rejection when now < nbf.
    #[test]
    fn test_verify_not_yet_valid() {
        let issuer = xdsa::SecretKey::generate();

        let cert = SimpleCert {
            sub: claims::Subject { sub: "test".into() },
            exp: None,
            nbf: claims::NotBefore { nbf: 1000000 },
            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
        };
        let token = issue(&cert, &issuer, b"test").expect("issue");
        let err = verify::<SimpleCert>(&token, &issuer.public_key(), b"test", Some(500000))
            .expect_err("should fail");

        assert!(matches!(err, Error::NotYetValid { .. }));
    }

    /// Tests rejection when now > exp.
    #[test]
    fn test_verify_expired() {
        let issuer = xdsa::SecretKey::generate();

        let cert = SimpleCert {
            sub: claims::Subject { sub: "test".into() },
            exp: Some(claims::Expiration { exp: 2000000 }),
            nbf: claims::NotBefore { nbf: 1000000 },
            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
        };
        let token = issue(&cert, &issuer, b"test").expect("issue");
        let err = verify::<SimpleCert>(&token, &issuer.public_key(), b"test", Some(3000000))
            .expect_err("should fail");

        assert!(matches!(err, Error::AlreadyExpired { .. }));
    }

    /// Tests rejection when nbf is absent and time check is on.
    #[test]
    fn test_verify_missing_nbf() {
        let issuer = xdsa::SecretKey::generate();

        let cert = NoNbfCert {
            sub: claims::Subject { sub: "test".into() },
            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
        };
        let token = issue(&cert, &issuer, b"test").expect("issue");
        let err = verify::<NoNbfCert>(&token, &issuer.public_key(), b"test", Some(1000000))
            .expect_err("should fail");

        assert!(matches!(err, Error::MissingNbf));
    }

    /// Tests rejection with wrong verifier key.
    #[test]
    fn test_verify_wrong_key() {
        let issuer = xdsa::SecretKey::generate();
        let wrong = xdsa::SecretKey::generate();

        let cert = SimpleCert {
            sub: claims::Subject { sub: "test".into() },
            exp: None,
            nbf: claims::NotBefore { nbf: 1000000 },
            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
        };
        let token = issue(&cert, &issuer, b"test").expect("issue");

        assert!(verify::<SimpleCert>(&token, &wrong.public_key(), b"test", Some(1500000)).is_err());
    }

    /// Tests fingerprint extraction from a token.
    #[test]
    fn test_signer() {
        let issuer = xdsa::SecretKey::generate();

        let cert = SimpleCert {
            sub: claims::Subject { sub: "test".into() },
            exp: None,
            nbf: claims::NotBefore { nbf: 1000000 },
            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
        };
        let token = issue(&cert, &issuer, b"test").expect("issue");
        let fp = signer(&token).expect("signer");

        assert_eq!(fp, issuer.public_key().fingerprint());
    }

    /// Tests unauthenticated claims extraction.
    #[test]
    fn test_peek() {
        let issuer = xdsa::SecretKey::generate();

        let cert = SimpleCert {
            sub: claims::Subject {
                sub: "peek-test".into(),
            },
            exp: None,
            nbf: claims::NotBefore { nbf: 1000000 },
            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
        };
        let token = issue(&cert, &issuer, b"test").expect("issue");
        let got: SimpleCert = peek(&token).expect("peek");

        assert_eq!(got.sub.sub, "peek-test");
    }

    /// Tests rejection when the verification domain differs.
    #[test]
    fn test_verify_wrong_domain() {
        let issuer = xdsa::SecretKey::generate();

        let cert = SimpleCert {
            sub: claims::Subject { sub: "test".into() },
            exp: None,
            nbf: claims::NotBefore { nbf: 1000000 },
            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
        };
        let token = issue(&cert, &issuer, b"domain-a").expect("issue");

        assert!(
            verify::<SimpleCert>(&token, &issuer.public_key(), b"domain-b", Some(1500000)).is_err()
        );
    }

    /// Tests that now == nbf passes and now == exp fails per RFC 8392.
    #[test]
    fn test_verify_boundary_exact() {
        let issuer = xdsa::SecretKey::generate();

        let cert = SimpleCert {
            sub: claims::Subject { sub: "test".into() },
            exp: Some(claims::Expiration { exp: 2000000 }),
            nbf: claims::NotBefore { nbf: 1000000 },
            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
        };
        let token = issue(&cert, &issuer, b"test").expect("issue");

        // now == nbf should pass
        verify::<SimpleCert>(&token, &issuer.public_key(), b"test", Some(1000000))
            .expect("now == nbf should pass");

        // now == exp should fail
        let err = verify::<SimpleCert>(&token, &issuer.public_key(), b"test", Some(2000000))
            .expect_err("now == exp should fail");
        assert!(matches!(err, Error::AlreadyExpired { .. }));
    }

    /// Tests that a token without exp passes time validation.
    #[test]
    fn test_verify_no_expiration() {
        let issuer = xdsa::SecretKey::generate();

        let cert = NoExpCert {
            sub: claims::Subject { sub: "test".into() },
            nbf: claims::NotBefore { nbf: 1000000 },
            cnf: claims::Confirm::new(xdsa::SecretKey::generate().public_key()),
        };
        let token = issue(&cert, &issuer, b"test").expect("issue");

        // Should pass even far in the future since there's no exp
        verify::<NoExpCert>(&token, &issuer.public_key(), b"test", Some(99999999))
            .expect("no exp should pass");
    }

    /// Tests that duplicate temporal claim keys are rejected.
    #[test]
    fn test_verify_duplicate_nbf() {
        let issuer = xdsa::SecretKey::generate();

        // Manually construct a CBOR map with duplicate nbf (key 5)
        let mut enc = cbor::Encoder::new();
        enc.encode_map_header(3);
        enc.encode_int(2);
        enc.encode_text("test");
        enc.encode_int(5);
        enc.encode_uint(1000000);
        enc.encode_int(5); // duplicate nbf
        enc.encode_uint(2000000);

        // Sign the raw payload via COSE (bypassing CWT's encoder)
        let token = cose::sign(Raw(enc.finish()), cbor::NULL, &issuer, b"test").expect("sign");

        // Verify should fail with DuplicateKey before claims decoding
        let err = verify::<SimpleCert>(&token, &issuer.public_key(), b"test", Some(1500000))
            .expect_err("should reject duplicate nbf");
        assert!(matches!(err, Error::DuplicateKey(5)));
    }

    /// Fixture corpus generated with v0.16.0 to pin the CWT wire format.
    const FIXTURES: &str = include_str!("testdata/v0.16.json");

    /// Retrieves a hex encoded field from the v0.16 fixture corpus.
    fn fixture(corpus: &serde_json::Value, key: &str) -> Vec<u8> {
        hex::decode(corpus[key].as_str().unwrap()).unwrap()
    }

    /// Tests that the v0.16 fixture corpus still validates, since that was in the
    /// first public release of the Ark, so we can't change the format anymore.
    #[test]
    fn test_v0_16_fixtures() {
        let corpus: serde_json::Value = serde_json::from_str(FIXTURES).unwrap();

        let xdsa_seed: [u8; 64] = fixture(&corpus, "xdsa_seed").try_into().unwrap();
        let signer = xdsa::SecretKey::from_bytes(&xdsa_seed);

        let domain = fixture(&corpus, "domain");
        let now = corpus["now"].as_u64().unwrap();

        // Verify the committed valid token and check the decoded claims
        let valid = fixture(&corpus, "valid");
        let got: SimpleCert = verify(&valid, &signer.public_key(), &domain, Some(now)).unwrap();
        assert_eq!(got.sub.sub, "fixture");
        assert_eq!(got.exp.unwrap().exp, 4102444800);
        assert_eq!(got.nbf.nbf, 1500000000);
        assert_eq!(got.cnf.key().to_bytes(), signer.public_key().to_bytes());

        // The expired and premature tokens must keep failing temporally
        let expired = fixture(&corpus, "expired");
        let err = verify::<SimpleCert>(&expired, &signer.public_key(), &domain, Some(now));
        assert!(matches!(err, Err(Error::AlreadyExpired { .. })));

        let premature = fixture(&corpus, "premature");
        let err = verify::<NoExpCert>(&premature, &signer.public_key(), &domain, Some(now));
        assert!(matches!(err, Err(Error::NotYetValid { .. })));

        // A tampered token must fail the signature check
        let mut tampered = valid.clone();
        *tampered.last_mut().unwrap() ^= 1;
        let err = verify::<SimpleCert>(&tampered, &signer.public_key(), &domain, Some(now));
        assert!(err.is_err());
    }
}