dpp-crypto 0.21.0

Ed25519 key management, JWS signing/verification, JAdES baseline construction, and an encrypted keystore
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
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
//! JAdES-B-B construction tests.
//!
//! Every assertion here is traceable to a clause of ETSI TS 119 182-1 V1.2.1,
//! and the clause is named. A test that pins a format against nothing but our
//! own reading of it is a test that agrees with us; naming the clause is what
//! lets the next reader check the reading rather than the code.

use base64::Engine;
use ed25519_dalek::{Signer, SigningKey, Verifier};
use serde_json::Value;

use super::*;

const B64: base64::engine::general_purpose::GeneralPurpose =
    base64::engine::general_purpose::URL_SAFE_NO_PAD;

/// A stand-in for a DER certificate. The bytes are never parsed by this module
/// — `x5t#S256` digests them and `x5c` carries them — so a fixed blob is enough
/// to exercise the format without pulling in a certificate generator.
const FAKE_DER: &[u8] = b"\x30\x82\x01\x0a-not-a-real-certificate-";

fn header() -> JadesHeader {
    JadesHeader {
        alg: "EdDSA".into(),
        iat: 1_770_000_000,
        certificate: CertificateRef::thumbprint_of_der(FAKE_DER),
        content_type: None,
    }
}

fn decode_header(compact: &str) -> Value {
    let seg = compact.split('.').next().expect("has a header segment");
    serde_json::from_slice(&B64.decode(seg).expect("header is base64url")).expect("header is JSON")
}

/// The header carries `alg`, a certificate reference and `iat` — and nothing
/// the standard does not ask for at B-B.
///
/// Table 1: `alg` shall be present; the claimed-signing-time service shall be
/// provided; the signing-certificate-reference service has cardinality 1.
#[test]
fn a_b_b_header_carries_exactly_what_the_standard_requires() {
    let prepared = prepare(&header(), b"payload").expect("prepares");
    let signed = prepared.assemble(&[0u8; 64]);
    let h = decode_header(signed.as_str());

    assert_eq!(h["alg"], "EdDSA");
    assert!(
        h.get("x5t#S256").is_some(),
        "clause 5.1.7 requires a reference"
    );
    assert!(h.get("iat").is_some(), "clause 5.1.11 requires iat");

    // Sorted, because `serde_json::Map` is a `BTreeMap` without the
    // `preserve_order` feature. JWS places no constraint on header key order,
    // and the property that matters — that the assembled bytes are the signed
    // bytes — comes from retaining the encoded segment, not from the ordering.
    let mut keys: Vec<&str> = h
        .as_object()
        .expect("object")
        .keys()
        .map(String::as_str)
        .collect();
    keys.sort_unstable();
    assert_eq!(
        keys,
        vec!["alg", "iat", "x5t#S256"],
        "no parameter the standard does not ask for at B-B"
    );
}

/// `iat` is an integer number of seconds.
///
/// Clause 5.1.11: the value *"shall be an integer number"* and *"shall not
/// contain fractions of seconds"*. A float here would be a conformance defect
/// that no signature check would ever catch.
#[test]
fn iat_is_a_whole_number_of_seconds() {
    let prepared = prepare(&header(), b"payload").expect("prepares");
    let h = decode_header(prepared.assemble(&[0u8; 64]).as_str());
    let iat = &h["iat"];
    assert!(iat.is_i64(), "iat must be an integer, got {iat}");
    assert_eq!(iat.as_i64(), Some(1_770_000_000));
    assert!(
        !serde_json::to_string(iat)
            .expect("serialises")
            .contains('.'),
        "iat must carry no fractional part"
    );
}

/// No `crit`, and no `sigD`, for an attached payload.
///
/// Clause 5.2.8.1: `sigD` *"shall not appear in JAdES signatures whose JWS
/// Payload is attached"*. Clause 5.1.9: `crit` is required only when `sigD` is
/// present — V1.2.1 **suppressed** V1.1.1's blanket requirement precisely so
/// that a signature without `sigD` stays processable by a plain JWS library.
///
/// This test is the one that would fail if someone "improved" the header by
/// adding `crit` from memory of the older version.
#[test]
fn an_attached_payload_emits_neither_sigd_nor_crit() {
    let prepared = prepare(&header(), b"payload").expect("prepares");
    let h = decode_header(prepared.assemble(&[0u8; 64]).as_str());
    assert!(
        h.get("sigD").is_none(),
        "clause 5.2.8.1 forbids sigD when attached"
    );
    assert!(
        h.get("crit").is_none(),
        "clause 5.1.9 requires crit only alongside sigD; V1.2.1 NOTE 1 explains why"
    );
}

/// The result is a plain RFC 7515 compact JWS, verifiable as one.
///
/// The payoff of the clause above: one artefact serves a verifier that
/// understands AdES and one that only understands JWS. Here the whole
/// round-trip runs through an ordinary Ed25519 verify.
#[test]
fn the_output_verifies_as_an_ordinary_jws() {
    let key = SigningKey::generate(&mut crate::os_rng());
    let payload = br#"{"passportId":"abc","productGroup":"battery"}"#;

    let prepared = prepare(&header(), payload).expect("prepares");
    let signature = key.sign(prepared.signing_input());
    let compact = prepared.assemble(&signature.to_bytes()).into_string();

    let parts: Vec<&str> = compact.split('.').collect();
    assert_eq!(parts.len(), 3, "compact serialisation is three segments");

    // Verify exactly as a plain JWS consumer would: recompute the signing input
    // from the wire, do not trust what we kept in memory.
    let signing_input = format!("{}.{}", parts[0], parts[1]);
    let sig_bytes: [u8; 64] = B64
        .decode(parts[2])
        .expect("signature is base64url")
        .try_into()
        .expect("Ed25519 signature is 64 bytes");
    key.verifying_key()
        .verify(
            signing_input.as_bytes(),
            &ed25519_dalek::Signature::from_bytes(&sig_bytes),
        )
        .expect("a JAdES-B-B signature verifies as a plain JWS");

    assert_eq!(
        B64.decode(parts[1]).expect("payload is base64url"),
        payload,
        "the payload round-trips unchanged"
    );
}

/// Tampering with the payload breaks the signature.
///
/// Content binding is the whole point; a format test that never checks it has
/// only tested the packaging.
#[test]
fn a_tampered_payload_no_longer_verifies() {
    let key = SigningKey::generate(&mut crate::os_rng());
    let prepared = prepare(&header(), b"original").expect("prepares");
    let signature = key.sign(prepared.signing_input());
    let compact = prepared.assemble(&signature.to_bytes()).into_string();

    let parts: Vec<&str> = compact.split('.').collect();
    let tampered = format!("{}.{}", parts[0], B64.encode(b"substituted"));
    let sig_bytes: [u8; 64] = B64.decode(parts[2]).expect("b64").try_into().expect("64");

    assert!(
        key.verifying_key()
            .verify(
                tampered.as_bytes(),
                &ed25519_dalek::Signature::from_bytes(&sig_bytes)
            )
            .is_err(),
        "a substituted payload must not verify"
    );
}

/// A signature over the *digest* of the signing input is the remote-signing
/// hand-off, and it commits to the same bytes.
///
/// This is the operation a provider's "sign this hash" endpoint performs, and
/// the test exists to pin that the digest is taken over the signing input —
/// not over the payload, which is the easy and silent mistake.
#[test]
fn the_digest_helper_commits_to_the_signing_input() {
    use sha2::{Digest, Sha256};
    let prepared = prepare(&header(), b"payload").expect("prepares");
    let expected: [u8; 32] = Sha256::digest(prepared.signing_input()).into();
    assert_eq!(prepared.signing_input_sha256(), expected);

    // And explicitly *not* a digest of the payload alone, which would sign
    // something that omits the header — including the certificate reference.
    let payload_only: [u8; 32] = Sha256::digest(b"payload").into();
    assert_ne!(
        prepared.signing_input_sha256(),
        payload_only,
        "the signature must cover the header, not only the payload"
    );
}

/// A missing certificate reference is refused, not silently omitted.
///
/// Clause 5.1.7 makes one of `x5t#S256`/`x5c`/`sigX5ts`/`x5t#o` mandatory. A
/// signature without one is not a JAdES signature, so producing one would be
/// producing something mislabelled.
#[test]
fn an_empty_certificate_chain_is_refused() {
    let h = JadesHeader {
        certificate: CertificateRef::Chain(vec![]),
        ..header()
    };
    assert_eq!(
        prepare(&h, b"payload").unwrap_err(),
        JadesError::EmptyCertificateChain
    );
}

/// `x5c` carries the chain as a JSON array, signing certificate first.
#[test]
fn x5c_carries_the_chain_in_order() {
    let h = JadesHeader {
        certificate: CertificateRef::Chain(vec!["c3ViamVjdA==".into(), "aXNzdWVy".into()]),
        ..header()
    };
    let prepared = prepare(&h, b"payload").expect("prepares");
    let decoded = decode_header(prepared.assemble(&[0u8; 64]).as_str());
    assert_eq!(
        decoded["x5c"],
        serde_json::json!(["c3ViamVjdA==", "aXNzdWVy"]),
        "x5c is an ordered array, signing certificate first (RFC 7515 4.1.6)"
    );
    assert!(
        decoded.get("x5t#S256").is_none(),
        "one reference form, not both, unless deliberately migrating"
    );
}

/// `x5t#S256` is the base64url SHA-256 of the DER certificate.
///
/// RFC 7515 clause 4.1.8. Checked against an independently computed digest
/// rather than against whatever the constructor happened to produce.
#[test]
fn the_thumbprint_is_the_sha256_of_the_der() {
    use sha2::{Digest, Sha256};
    let CertificateRef::Thumbprint(t) = CertificateRef::thumbprint_of_der(FAKE_DER) else {
        panic!("constructed a thumbprint");
    };
    assert_eq!(t, B64.encode(Sha256::digest(FAKE_DER)));
    assert!(!t.contains('='), "base64url without padding");
}

/// `cty` appears only when set.
#[test]
fn content_type_is_optional_and_emitted_when_present() {
    let plain = prepare(&header(), b"p").expect("prepares");
    assert!(
        decode_header(plain.assemble(&[0u8; 64]).as_str())
            .get("cty")
            .is_none()
    );

    let typed = prepare(&header().with_content_type("json"), b"p").expect("prepares");
    assert_eq!(
        decode_header(typed.assemble(&[0u8; 64]).as_str())["cty"],
        "json"
    );
}

/// The header bytes are stable across calls.
///
/// They are signed, so two serialisations that differ only in key order are two
/// different signing inputs. A verifier recomputing the header from a
/// round-tripped structure would then disagree with the signature for no
/// reason a reader could see.
#[test]
fn header_serialisation_is_stable() {
    let h = header().with_content_type("json");
    let once = h.to_json_bytes().expect("serialises");
    for _ in 0..64 {
        assert_eq!(h.to_json_bytes().expect("serialises"), once);
    }
}

// ─── Table 1 conformance, mechanically ───────────────────────────────────────

/// Check a compact JAdES against every JAdES-B-B rule this module claims to
/// implement, returning the rules it broke.
///
/// The per-rule tests above each pin one clause in isolation. This applies all
/// of them to one artefact at once, so a header that satisfies each rule
/// separately but not together is still caught — and a new test case gets the
/// whole checklist rather than whichever assertions its author remembered.
///
/// It is still *our* reading of the standard. The check that is not ours lives
/// in `.github/oracle/jades/`, which hands an artefact to the European
/// Commission's reference implementation and asks what it is.
fn table_1_b_b_violations(compact: &str) -> Vec<String> {
    let mut bad = Vec::new();
    let parts: Vec<&str> = compact.split('.').collect();
    if parts.len() != 3 {
        return vec![format!(
            "compact form has {} segments, expected 3",
            parts.len()
        )];
    }

    let Ok(header_bytes) = B64.decode(parts[0]) else {
        return vec!["protected header is not base64url".to_owned()];
    };
    let Ok(h) = serde_json::from_slice::<Value>(&header_bytes) else {
        return vec!["protected header is not JSON".to_owned()];
    };
    let Some(obj) = h.as_object() else {
        return vec!["protected header is not a JSON object".to_owned()];
    };

    // Table 1: alg shall be present, cardinality 1.
    if !obj.get("alg").is_some_and(Value::is_string) {
        bad.push("alg absent or not a string (Table 1, clause 5.1.2)".to_owned());
    }

    // Clause 5.1.7: at least one signing-certificate reference.
    let refs = ["x5t#S256", "x5c", "sigX5ts", "x5t#o"];
    if !refs.iter().any(|k| obj.contains_key(*k)) {
        bad.push(format!(
            "no signing-certificate reference; need one of {refs:?} (clause 5.1.7)"
        ));
    }

    // Clause 5.1.11: iat present, integer, no fractional part.
    match obj.get("iat") {
        None => bad.push("iat absent (clause 5.1.11, mandatory since 2025-07-15)".to_owned()),
        Some(v) if !v.is_i64() => bad.push(format!("iat is not an integer: {v}")),
        Some(_) => {}
    }

    // Clause 5.2.8.1: sigD shall not appear when the payload is attached, and
    // everything this module builds is attached.
    if obj.contains_key("sigD") {
        bad.push("sigD present on an attached payload (clause 5.2.8.1)".to_owned());
    }

    // Clause 5.1.9: crit is required *only* alongside sigD. Emitting it without
    // sigD does not break the letter, but it breaks the intent — V1.2.1
    // suppressed the blanket rule so a JAdES without sigD stays processable by
    // a plain JWS library, and a gratuitous crit takes that back.
    if obj.contains_key("crit") && !obj.contains_key("sigD") {
        bad.push("crit present without sigD (clause 5.1.9 NOTE 1)".to_owned());
    }

    if B64.decode(parts[1]).is_err() {
        bad.push("payload segment is not base64url".to_owned());
    }
    if B64.decode(parts[2]).is_err() {
        bad.push("signature segment is not base64url".to_owned());
    }
    bad
}

/// Every shape this module can produce satisfies Table 1 at B-B.
///
/// The payload cases are the ones most likely to break an encoder: empty,
/// binary that is not valid UTF-8, multi-byte characters, and something large
/// enough to cross buffer boundaries.
#[test]
fn every_producible_shape_satisfies_table_1() {
    let payloads: Vec<Vec<u8>> = vec![
        b"".to_vec(),
        b"{}".to_vec(),
        vec![0x00, 0xff, 0xfe, 0x80, 0x7f],
        "product_group: electrique — battery".as_bytes().to_vec(),
        vec![b'x'; 64 * 1024],
    ];
    let headers = vec![
        header(),
        header().with_content_type("json"),
        JadesHeader {
            certificate: CertificateRef::Chain(vec!["Y2VydA==".into(), "aXNzdWVy".into()]),
            ..header()
        },
        JadesHeader {
            alg: "ES256".into(),
            ..header()
        },
    ];

    let mut checked = 0usize;
    for h in &headers {
        for p in &payloads {
            let prepared = prepare(h, p).expect("prepares");
            let compact = prepared.assemble(&[7u8; 64]).into_string();
            let violations = table_1_b_b_violations(&compact);
            assert!(
                violations.is_empty(),
                "alg={} payload={} bytes violated: {violations:?}",
                h.alg,
                p.len()
            );
            checked += 1;
        }
    }
    assert_eq!(
        checked, 20,
        "every header/payload combination was exercised"
    );
}

/// The checker rejects what it is supposed to reject.
///
/// A conformance checker nobody has watched fail is a checker that might be
/// asserting nothing. Each case below changes exactly one thing.
#[test]
fn the_table_1_checker_catches_each_violation() {
    fn with_header(mutate: impl FnOnce(&mut serde_json::Map<String, Value>)) -> String {
        let prepared = prepare(&header(), b"payload").expect("prepares");
        let compact = prepared.assemble(&[0u8; 64]).into_string();
        let parts: Vec<&str> = compact.split('.').collect();
        let mut h: serde_json::Map<String, Value> =
            serde_json::from_slice(&B64.decode(parts[0]).expect("b64")).expect("json");
        mutate(&mut h);
        format!(
            "{}.{}.{}",
            B64.encode(serde_json::to_vec(&Value::Object(h)).expect("json")),
            parts[1],
            parts[2]
        )
    }

    let cases: Vec<(&str, String, &str)> = vec![
        (
            "missing alg",
            with_header(|h| {
                h.remove("alg");
            }),
            "alg absent",
        ),
        (
            "missing certificate reference",
            with_header(|h| {
                h.remove("x5t#S256");
            }),
            "signing-certificate reference",
        ),
        (
            "missing iat",
            with_header(|h| {
                h.remove("iat");
            }),
            "iat absent",
        ),
        (
            "fractional iat",
            with_header(|h| {
                h.insert("iat".into(), serde_json::json!(1_770_000_000.5_f64));
            }),
            "not an integer",
        ),
        (
            "sigD on an attached payload",
            with_header(|h| {
                h.insert("sigD".into(), serde_json::json!({"pars": []}));
            }),
            "sigD present",
        ),
        (
            "crit without sigD",
            with_header(|h| {
                h.insert("crit".into(), serde_json::json!(["iat"]));
            }),
            "crit present without sigD",
        ),
    ];

    for (name, compact, expected) in cases {
        let violations = table_1_b_b_violations(&compact);
        assert!(
            violations.iter().any(|v| v.contains(expected)),
            "{name}: expected a violation mentioning {expected:?}, got {violations:?}"
        );
    }

    // The unmodified artefact passes, so each case above fails for the reason
    // stated rather than because everything fails.
    let clean = prepare(&header(), b"payload")
        .expect("prepares")
        .assemble(&[0u8; 64])
        .into_string();
    assert!(table_1_b_b_violations(&clean).is_empty());
}

/// `now()` claims a signing time in whole seconds, close to now.
#[test]
fn now_claims_a_plausible_whole_second_signing_time() {
    let before = chrono::Utc::now().timestamp();
    let h = JadesHeader::now("EdDSA", CertificateRef::thumbprint_of_der(FAKE_DER));
    let after = chrono::Utc::now().timestamp();
    assert!(
        (before..=after).contains(&h.iat),
        "iat {} is outside [{before}, {after}]",
        h.iat
    );
    let compact = prepare(&h, b"p").expect("prepares").assemble(&[0u8; 64]);
    assert!(table_1_b_b_violations(compact.as_str()).is_empty());
}

/// The module is indifferent to signature length, because the algorithm is the
/// caller's business.
///
/// `ES256` produces 64 bytes, `RS256` 256, Ed25519 64. Baking in an expectation
/// would silently restrict which providers could be used — the opposite of the
/// point of building the format ourselves.
#[test]
fn signature_length_is_not_constrained() {
    for len in [64usize, 96, 128, 256, 384, 512] {
        let compact = prepare(&header(), b"payload")
            .expect("prepares")
            .assemble(&vec![0xabu8; len])
            .into_string();
        assert!(table_1_b_b_violations(&compact).is_empty(), "len {len}");
        let sig = B64
            .decode(compact.split('.').nth(2).expect("sig segment"))
            .expect("b64");
        assert_eq!(sig.len(), len, "the signature round-trips at {len} bytes");
    }
}

/// An empty payload still produces a well-formed signature.
///
/// `base64url("")` is the empty string, so the compact form has an empty middle
/// segment — legal in JWS, and the kind of thing a naive assembler gets wrong.
#[test]
fn an_empty_payload_produces_an_empty_middle_segment() {
    let compact = prepare(&header(), b"")
        .expect("prepares")
        .assemble(&[0u8; 64])
        .into_string();
    let parts: Vec<&str> = compact.split('.').collect();
    assert_eq!(parts.len(), 3);
    assert!(parts[1].is_empty(), "an empty payload encodes to nothing");
    assert!(table_1_b_b_violations(&compact).is_empty());
}

/// No segment carries base64 padding.
///
/// RFC 7515 clause 2 requires base64url **without** padding. A `=` anywhere is
/// a signature other implementations will reject.
#[test]
fn no_segment_carries_base64_padding() {
    // A payload length that is not a multiple of three, so padding would show
    // if the encoder emitted any.
    let compact = prepare(&header().with_content_type("json"), b"1234567890abcdefghij")
        .expect("prepares")
        .assemble(&[9u8; 64])
        .into_string();
    assert!(
        !compact.contains('='),
        "base64url in JWS is unpadded (RFC 7515 clause 2): {compact}"
    );
}

/// The combined form emits both `x5c` and `x5t#S256`.
///
/// Clause 5.1.7 permits either alone; Table 1's "signing a reference of the
/// signing certificate" service admits only the digest forms. A signature with
/// `x5c` alone therefore satisfies 5.1.7 and is still not baseline — the
/// European Commission's DSS reported exactly that as `JSON-NOT-ETSI`, warning
/// that the signing-certificate attribute was absent.
///
/// Found by an outside implementation rather than by reading, which is the
/// whole reason the oracle exists.
#[test]
fn the_combined_form_carries_the_chain_and_the_digest() {
    let der = FAKE_DER.to_vec();
    let cert = CertificateRef::chain_of_der(std::slice::from_ref(&der)).expect("one certificate");
    let h = JadesHeader {
        certificate: cert,
        ..header()
    };
    let decoded = decode_header(
        prepare(&h, b"payload")
            .expect("prepares")
            .assemble(&[0u8; 64])
            .as_str(),
    );

    assert!(
        decoded.get("x5c").is_some(),
        "the chain travels with the signature"
    );
    let CertificateRef::Thumbprint(expected) = CertificateRef::thumbprint_of_der(&der) else {
        unreachable!()
    };
    assert_eq!(
        decoded["x5t#S256"],
        serde_json::json!(expected),
        "and the digest reference Table 1 requires for baseline"
    );
    assert!(
        table_1_b_b_violations(
            prepare(&h, b"payload")
                .expect("prepares")
                .assemble(&[0u8; 64])
                .as_str()
        )
        .is_empty()
    );
}

/// An empty chain is refused by the combined constructor too.
#[test]
fn the_combined_constructor_refuses_an_empty_chain() {
    assert_eq!(
        CertificateRef::chain_of_der(&[]).unwrap_err(),
        JadesError::EmptyCertificateChain
    );
}

// ── The EU profile, which lives in a Regulation and not in the standard ──────

#[test]
fn only_the_chain_with_thumbprint_form_is_in_the_eu_recognised_profile() {
    // Commission Implementing Regulation (EU) 2026/248 Annex I makes `x5c`
    // mandatory; TS 119 182-1 Table 1 makes a digest reference mandatory. Each
    // of the other two variants satisfies exactly one.
    assert!(
        !CertificateRef::thumbprint_of_der(FAKE_DER).is_eu_recognised_profile(),
        "x5t#S256 alone carries no x5c, so Annex I does not reach it"
    );
    assert!(
        !CertificateRef::Chain(vec!["Y2VydA==".into()]).is_eu_recognised_profile(),
        "x5c alone fails Table 1's certificate-reference service"
    );
    assert!(
        CertificateRef::chain_of_der(std::slice::from_ref(&FAKE_DER.to_vec()))
            .expect("one certificate")
            .is_eu_recognised_profile()
    );
}

#[test]
fn the_eu_recognised_form_actually_emits_x5c() {
    // Pins the adaptation itself rather than the variant name. The requirement
    // lives in a Regulation, so a reader checking this module against the ETSI
    // document alone would never meet it — this is where it is written down in a
    // form that fails if the emitted header changes.
    let header = JadesHeader::now(
        "EdDSA",
        CertificateRef::chain_of_der(std::slice::from_ref(&FAKE_DER.to_vec()))
            .expect("one certificate"),
    );
    let bytes = header.to_json_bytes().expect("header builds");
    let protected: serde_json::Map<String, serde_json::Value> =
        serde_json::from_slice(&bytes).expect("the protected header is a JSON object");

    assert!(
        header.certificate.is_eu_recognised_profile(),
        "the form under test must be the one Annex I lists"
    );
    assert!(
        protected.contains_key("x5c"),
        "Annex I: the x5c header parameter shall be present — got {:?}",
        protected.keys().collect::<Vec<_>>()
    );
    assert!(
        protected.contains_key("x5t#S256"),
        "TS 119 182-1 Table 1 still wants a digest reference alongside it"
    );
}

/// 🚨 `is_eu_recognised_profile` answered on the variant alone, so a
/// `ChainWithThumbprint` carrying nothing was reported as the EU-recognised
/// format.
///
/// `chain_of_der` cannot build one — but the variant's fields are public, so
/// this value is one any caller can make, and a compliance caller acting on the
/// answer would have accepted a header identifying no certificate at all. Both
/// legs the method documents are *presence* requirements: Annex I's `x5c`
/// "shall be present", and Table 1's digest reference with cardinality 1. An
/// empty one is present and references nothing.
#[test]
fn an_empty_chain_or_thumbprint_is_not_the_eu_recognised_profile() {
    let cases = [
        (
            "empty chain",
            CertificateRef::ChainWithThumbprint {
                chain: vec![],
                thumbprint: "abc".into(),
            },
        ),
        (
            "empty thumbprint",
            CertificateRef::ChainWithThumbprint {
                chain: vec!["Y2VydA==".into()],
                thumbprint: String::new(),
            },
        ),
        (
            "both empty",
            CertificateRef::ChainWithThumbprint {
                chain: vec![],
                thumbprint: String::new(),
            },
        ),
    ];

    for (name, reference) in cases {
        assert!(
            !reference.is_eu_recognised_profile(),
            "{name} was reported as the format a Member State body must recognise"
        );
    }

    // And the populated form still is, so the fix is not "always false".
    assert!(
        CertificateRef::ChainWithThumbprint {
            chain: vec!["Y2VydA==".into()],
            thumbprint: "abc".into(),
        }
        .is_eu_recognised_profile()
    );
}

/// The serialiser has to refuse the same two, or it writes a header the
/// predicate has just said is not conformant. It caught the empty chain and not
/// the empty thumbprint.
#[test]
fn a_header_with_an_empty_thumbprint_is_refused_like_an_empty_chain() {
    let h = JadesHeader {
        certificate: CertificateRef::ChainWithThumbprint {
            chain: vec!["Y2VydA==".into()],
            thumbprint: String::new(),
        },
        ..header()
    };
    assert_eq!(
        prepare(&h, b"payload").expect_err("an empty x5t#S256 references no certificate"),
        JadesError::EmptyThumbprint
    );
}

/// 🚨 The boundary, asserted so it is not read as coverage: a thumbprint that is
/// present but is **not** the digest of `chain[0]` is still accepted.
///
/// That is a question about whether the reference is *correct*, where this
/// method's scope is whether the format is *present*. Answering it needs the
/// DER to hash, which a predicate on the header does not have. Recorded here so
/// the next reader knows it was considered rather than missed — if this starts
/// failing, the scope has widened and this test should be replaced by one
/// asserting the match.
#[test]
fn a_thumbprint_that_does_not_match_the_chain_is_still_accepted_and_this_is_the_boundary() {
    let mismatched = CertificateRef::ChainWithThumbprint {
        chain: vec!["Y2VydA==".into()],
        thumbprint: "not-the-digest-of-that-certificate".into(),
    };
    assert!(
        mismatched.is_eu_recognised_profile(),
        "if this now fails, the predicate checks correspondence and this test is stale"
    );
}