• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

randombit / botan / 29630228535

18 Jul 2026 02:46AM UTC coverage: 89.407% (-2.3%) from 91.66%
29630228535

push

github

web-flow
Merge pull request #5740 from randombit/jack/x509-serial-number

Add an explicit type for X509 certificate serial numbers

114089 of 127606 relevant lines covered (89.41%)

10801388.62 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.52
/src/tests/test_name_constraint.cpp
1
/*
2
* (C) 2015,2016 Kai Michaelis
3
*     2026 Jack Lloyd
4
*
5
* Botan is released under the Simplified BSD License (see license.txt)
6
*/
7

8
#include "tests.h"
9

10
#if defined(BOTAN_HAS_X509_CERTIFICATES)
11
   #include <botan/ber_dec.h>
12
   #include <botan/data_src.h>
13
   #include <botan/der_enc.h>
14
   #include <botan/hex.h>
15
   #include <botan/pkix_types.h>
16
   #include <botan/x509_ext.h>
17
   #include <botan/x509cert.h>
18
   #include <botan/x509path.h>
19
   #include <botan/internal/calendar.h>
20
   #include <botan/internal/x509_utils.h>
21
   #include <algorithm>
22
   #include <fstream>
23
   #include <set>
24
#endif
25

26
namespace Botan_Tests {
27

28
namespace {
29

30
#if defined(BOTAN_HAS_X509_CERTIFICATES) && defined(BOTAN_HAS_ECDSA) && defined(BOTAN_HAS_SHA2_32) && \
31
   defined(BOTAN_TARGET_OS_HAS_FILESYSTEM)
32

33
// Read all certificates from a PEM bundle in file order (leaf first).
34
std::vector<Botan::X509_Certificate> load_chain(const std::string& filename) {
599✔
35
   Botan::DataSource_Stream in(filename);
599✔
36
   std::vector<Botan::X509_Certificate> certs;
599✔
37
   while(!in.end_of_data()) {
1,840✔
38
      try {
1,840✔
39
         certs.emplace_back(in);
1,840✔
40
      } catch(const Botan::Decoding_Error&) {
599✔
41
         break;
599✔
42
      }
599✔
43
   }
44
   return certs;
599✔
45
}
599✔
46

47
// (type, name) pairs for order-preserving comparison of subtree lists
48
std::vector<std::pair<Botan::GeneralName::NameType, std::string>> describe(
28✔
49
   const std::vector<Botan::GeneralSubtree>& subtrees) {
50
   std::vector<std::pair<Botan::GeneralName::NameType, std::string>> out;
28✔
51
   out.reserve(subtrees.size());
28✔
52
   for(const auto& subtree : subtrees) {
62✔
53
      out.emplace_back(subtree.base().type_code(), subtree.base().name());
34✔
54
   }
55
   return out;
28✔
56
}
×
57

58
// Parse `<chain-name>:<result>` lines; ignore blanks and `#` comments.
59
std::vector<std::pair<std::string, std::string>> read_manifest(const std::string& path) {
2✔
60
   std::vector<std::pair<std::string, std::string>> out;
2✔
61
   std::ifstream in(path);
2✔
62
   std::string line;
2✔
63
   while(std::getline(in, line)) {
610✔
64
      if(line.empty() || line.front() == '#') {
608✔
65
         continue;
10✔
66
      }
67
      const auto colon = line.find(':');
598✔
68
      if(colon == std::string::npos) {
598✔
69
         continue;
×
70
      }
71
      out.emplace_back(line.substr(0, colon), line.substr(colon + 1));
1,196✔
72
   }
73
   return out;
4✔
74
}
2✔
75

76
class Name_Constraint_Validation_Tests final : public Test {
1✔
77
   public:
78
      std::vector<Test::Result> run() override {
1✔
79
         std::vector<Test::Result> results;
1✔
80

81
         /*
82
         * Each test is a single PEM file containing the chain leaf-first (leaf,
83
         * intermediates...), the trust anchor is shared as root.pem, and expected.txt
84
         * maps test-name to the Path_Validation_Result::result_string() output.
85
         */
86
         const std::string base = "x509/name_constraints/";
1✔
87
         const auto expected = read_manifest(Test::data_file(base + "expected.txt"));
2✔
88

89
         const Botan::X509_Certificate trust_anchor(Test::data_file(base + "root.pem"));
2✔
90

91
         const auto when = Botan::calendar_point(2027, 1, 1, 0, 0, 0).to_std_timepoint();
1✔
92

93
         const Botan::Path_Validation_Restrictions restrictions(false, 128);
1✔
94

95
         for(const auto& [name, expected_result] : expected) {
300✔
96
            Test::Result result("Name constraints test " + name);
299✔
97

98
            Botan::Certificate_Store_In_Memory store;
299✔
99
            store.add_certificate(trust_anchor);
299✔
100

101
            const auto chain = load_chain(Test::data_file(base + name + ".pem"));
897✔
102
            if(chain.empty()) {
299✔
103
               result.test_str_eq("validation result", "Certificate failed to decode", expected_result);
4✔
104
               results.emplace_back(std::move(result));
4✔
105
               continue;
4✔
106
            }
107

108
            const std::string hostname;
295✔
109

110
            const auto pv =
295✔
111
               Botan::x509_path_validate(chain, restrictions, store, hostname, Botan::Usage_Type::UNSPECIFIED, when);
295✔
112

113
            result.test_str_eq("validation result", pv.result_string(), expected_result);
295✔
114
            results.emplace_back(std::move(result));
295✔
115
         }
299✔
116

117
         return results;
2✔
118
      }
1✔
119
};
120

121
BOTAN_REGISTER_TEST("x509", "x509_name_constraints", Name_Constraint_Validation_Tests);
122

123
/*
124
* Validate that GeneralName iPAddress decoding rejects masks that are not a
125
* contiguous CIDR prefix. Drives the decoder with hand-rolled BER for a
126
* single [7] IMPLICIT OCTET STRING carrying {net || mask}.
127
*/
128
class Name_Constraint_IP_Mask_Tests final : public Text_Based_Test {
×
129
   public:
130
      Name_Constraint_IP_Mask_Tests() : Text_Based_Test("x509/general_name_ip.vec", "Address,Netmask") {}
2✔
131

132
      Test::Result run_one_test(const std::string& header, const VarMap& vars) override {
19✔
133
         Test::Result result("GeneralName iPAddress mask validation");
19✔
134

135
         const auto address = vars.get_req_bin("Address");
19✔
136
         const auto netmask = vars.get_req_bin("Netmask");
19✔
137

138
         const auto der = encode_address(address, netmask);
19✔
139

140
         Botan::BER_Decoder decoder(der, Botan::BER_Decoder::Limits::DER());
19✔
141
         Botan::GeneralName gn;
19✔
142

143
         if(header == "Valid") {
19✔
144
            try {
12✔
145
               gn.decode_from(decoder);
12✔
146
               result.test_success("Accepted valid GeneralName IP encoding");
12✔
147
            } catch(Botan::Decoding_Error&) {
×
148
               result.test_failure("Rejected valid GeneralName IP encoding");
×
149
            }
×
150
         } else {
151
            try {
7✔
152
               gn.decode_from(decoder);
7✔
153
               result.test_failure("Accepted invalid GeneralName IP encoding");
×
154
            } catch(Botan::Decoding_Error&) {
7✔
155
               result.test_success("Rejected invalid GeneralName IP encoding");
7✔
156
            }
7✔
157
         }
158

159
         return result;
38✔
160
      }
19✔
161

162
   private:
163
      static std::vector<uint8_t> encode_address(std::span<const uint8_t> address, std::span<const uint8_t> netmask) {
19✔
164
         std::vector<uint8_t> der;
19✔
165
         // [7] IMPLICIT OCTET STRING, primitive, context-specific.
166
         der.push_back(0x87);
19✔
167
         // Short for length is sufficient here
168
         der.push_back(static_cast<uint8_t>(address.size() + netmask.size()));
19✔
169
         der.insert(der.end(), address.begin(), address.end());
19✔
170
         der.insert(der.end(), netmask.begin(), netmask.end());
19✔
171
         return der;
19✔
172
      }
×
173
};
174

175
BOTAN_REGISTER_TEST("x509", "x509_name_constraint_ip_mask", Name_Constraint_IP_Mask_Tests);
176

177
/*
178
* Strict validation at the constraint-factory boundary: malformed
179
* inputs throw, valid inputs are canonicalized (lowercase host,
180
* preserve email local-part case).
181
*/
182
class Name_Constraint_Factory_Validation_Tests final : public Test {
1✔
183
   private:
184
      using FactoryFn = Botan::GeneralName (*)(std::string_view);
185

186
      static void check_valid(Test::Result& result,
21✔
187
                              const std::string& label,
188
                              FactoryFn make,
189
                              std::string_view input,
190
                              std::string_view expected_name) {
191
         try {
21✔
192
            const auto gn = make(input);
21✔
193
            result.test_str_eq(label + " canonical: " + std::string(input), gn.name(), expected_name);
63✔
194
         } catch(const std::exception& e) {
21✔
195
            result.test_failure(label + " rejected valid '" + std::string(input) + "': " + e.what());
×
196
         }
×
197
      }
21✔
198

199
      static void check_invalid(Test::Result& result,
42✔
200
                                const std::string& label,
201
                                FactoryFn make,
202
                                std::string_view input) {
203
         try {
42✔
204
            (void)make(input);
42✔
205
            result.test_failure(label + " accepted invalid '" + std::string(input) + "'");
×
206
         } catch(const Botan::Invalid_Argument&) {
42✔
207
            result.test_success(label + " rejected '" + std::string(input) + "'");
168✔
208
         }
42✔
209
      }
42✔
210

211
      static Test::Result test_dns() {
1✔
212
         Test::Result result("X509v3 Name Constraints: DNS factory validation");
1✔
213
         const auto m = &Botan::GeneralName::dns;
1✔
214
         check_valid(result, "DNS", m, "example.com", "example.com");
1✔
215
         check_valid(result, "DNS", m, "EXAMPLE.com", "example.com");
1✔
216
         check_valid(result, "DNS", m, "host", "host");
1✔
217
         check_valid(result, "DNS", m, ".example.com", ".example.com");
1✔
218

219
         const auto rejected = {"",
1✔
220
                                ".",
221
                                "..example.com",
222
                                "example..com",
223
                                "example.com.",
224
                                "*.example.com",
225
                                "host name",
226
                                " example.com",
227
                                "example.com ",
228
                                "_acme-challenge.example.com"};
1✔
229

230
         for(const auto& bad : rejected) {
11✔
231
            check_invalid(result, "DNS", m, bad);
20✔
232
         }
233
         return result;
1✔
234
      }
×
235

236
      static Test::Result test_uri() {
1✔
237
         Test::Result result("X509v3 Name Constraints: URI factory validation");
1✔
238
         const auto m = &Botan::GeneralName::uri;
1✔
239
         check_valid(result, "URI", m, "example.com", "example.com");
1✔
240
         check_valid(result, "URI", m, ".example.com", ".example.com");
1✔
241
         check_valid(result, "URI", m, "EXAMPLE.com", "example.com");
1✔
242
         // RFC 5280 4.2.1.10: "The constraint MUST be specified as a
243
         // fully qualified domain name". Single-label hosts and full
244
         // URIs are not constraint-shaped; both are rejected.
245
         for(const auto& bad : {"",
21✔
246
                                ".",
247
                                "localhost",
248
                                ".localhost",
249
                                "https://example.com",
250
                                "https://example.com/path",
251
                                "example.com:443",
252
                                "*.example.com",
253
                                "example.com.",
254
                                "..example.com"}) {
11✔
255
            check_invalid(result, "URI", m, bad);
20✔
256
         }
257
         return result;
1✔
258
      }
×
259

260
      static Test::Result test_uri_san_value() {
1✔
261
         Test::Result result("X509v3 Name Constraints: URI SAN value factory validation");
1✔
262
         const auto m = &Botan::GeneralName::_uri_san_value;
1✔
263
         check_valid(result, "URI SAN", m, "https://example.com", "https://example.com");
1✔
264
         check_valid(result, "URI SAN", m, "https://example.com/path?q=1#frag", "https://example.com/path?q=1#frag");
1✔
265
         check_valid(result, "URI SAN", m, "HTTPS://Example.COM/", "HTTPS://Example.COM/");
1✔
266
         check_valid(result, "URI SAN", m, "https://localhost/", "https://localhost/");
1✔
267
         check_valid(result, "URI SAN", m, "mailto:root@example.com", "mailto:root@example.com");
1✔
268
         // Inputs URI::parse rejects (RFC 3986 syntax violations,
269
         // constraint-shape values that aren't URIs).
270
         for(const auto& bad : {"",
17✔
271
                                "example.com",
272
                                ".example.com",
273
                                "not a uri",
274
                                "://no.scheme/",
275
                                "https://example.com/has space",
276
                                "https://user@bad@example.com/",
277
                                "https://example.com/%G0"}) {
9✔
278
            check_invalid(result, "URI SAN", m, bad);
16✔
279
         }
280
         return result;
1✔
281
      }
×
282

283
      static Test::Result test_dns_san_value() {
1✔
284
         Test::Result result("X509v3 Name Constraints: DNS SAN value factory validation");
1✔
285
         const auto m = &Botan::GeneralName::_dns_san_value;
1✔
286

287
         check_valid(result, "DNS SAN", m, "example.com", "example.com");
1✔
288
         check_valid(result, "DNS SAN", m, "EXAMPLE.com", "example.com");
1✔
289
         check_valid(result, "DNS SAN", m, "*.example.com", "*.example.com");
1✔
290
         check_valid(result, "DNS SAN", m, "foo*.example.com", "foo*.example.com");
1✔
291
         check_valid(result, "DNS SAN", m, "*bar.example.com", "*bar.example.com");
1✔
292

293
         for(const auto& bad : {"", ".", "..example.com", "*.*.example.com", "foo.*.example.com", "host name"}) {
7✔
294
            check_invalid(result, "DNS SAN", m, bad);
12✔
295
         }
296
         return result;
1✔
297
      }
×
298

299
      static Test::Result test_email() {
1✔
300
         Test::Result result("X509v3 Name Constraints: email factory validation");
1✔
301
         const auto m = &Botan::GeneralName::email;
1✔
302
         // Mailbox form: local-part case-preserved, host lowercased (RFC 5280 7.5).
303
         check_valid(result, "Email", m, "Alice@Example.COM", "Alice@example.com");
1✔
304
         check_valid(result, "Email", m, "user@example.com", "user@example.com");
1✔
305
         // Host form: bare DNS name.
306
         check_valid(result, "Email", m, "example.com", "example.com");
1✔
307
         // Subtree form: leading dot is preserved.
308
         check_valid(result, "Email", m, ".example.com", ".example.com");
1✔
309
         for(const auto& bad : {"",
17✔
310
                                "@example.com",
311
                                "user@",
312
                                "a@b@c",
313
                                ".",
314
                                "user@example..com",
315
                                "user@.example.com",
316
                                "user@*.example.com"}) {
9✔
317
            check_invalid(result, "Email", m, bad);
16✔
318
         }
319
         return result;
1✔
320
      }
×
321

322
   public:
323
      std::vector<Test::Result> run() override {
1✔
324
         return {test_dns(), test_uri(), test_email(), test_uri_san_value(), test_dns_san_value()};
6✔
325
      }
1✔
326
};
327

328
BOTAN_REGISTER_TEST("x509", "x509_name_constraint_factory_validation", Name_Constraint_Factory_Validation_Tests);
329

330
class Wildcard_Excluded_Subtree_Containment_Tests final : public Test {
1✔
331
   public:
332
      std::vector<Test::Result> run() override {
1✔
333
         Test::Result result("X509v3 Name Constraints: wildcard SAN vs excluded DNS subtree");
1✔
334

335
         struct Case {
28✔
336
               std::string pattern;     // SAN wildcard
337
               std::string constraint;  // excluded DNS constraint value
338
               bool expect_intersect;
339
         };
340

341
         const std::vector<Case> cases = {
1✔
342
            // SAN of *.com can expand to evil.com.
343
            {"*.com", "evil.com", true},
1✔
344
            // Leading-dot subtree: *.com can expand to <anything>.com.
345
            {"*.com", ".com", true},
346
            // Wildcard whose tail equals the constraint: every expansion
347
            // is in the subtree.
348
            {"*.example.com", "example.com", true},
349
            {"*.example.com", ".example.com", true},
350
            // Wildcard with extra labels under the constraint: every
351
            // expansion is in the subtree.
352
            {"*.foo.example.com", "example.com", true},
353
            // Partial wildcards in the leftmost label that absorb the
354
            // missing labels of the constraint base.
355
            {"foo*.example.com", "example.com", true},
356
            {"*bar.example.com", "example.com", true},
357
            // Non-overlapping suffixes: no expansion in subtree.
358
            {"*.example.com", "evil.com", false},
359
            {"*.example.com", ".other.com", false},
360
            // Wildcard tail shorter than constraint, and leftover prefix
361
            // contains a dot - can't be produced by a single-label wildcard.
362
            {"*.com", "evil.example.com", false},
363
            // Single-label wildcards only match single-label hosts.
364
            {"*", "evil.com", false},
365
            {"*", "com", true},
366
            {"foo*", "foobar", true},
367
            // Leading-dot subtree excludes the apex; single-label wildcard
368
            // can't reach into it.
369
            {"*", ".com", false},
370
         };
15✔
371

372
         for(const auto& c : cases) {
15✔
373
            const bool got = Botan::wildcard_intersects_excluded_dns_subtree(c.pattern, c.constraint);
14✔
374
            result.test_bool_eq(c.pattern + " vs " + c.constraint, got, c.expect_intersect);
42✔
375
         }
376

377
         return {result};
3✔
378
      }
4✔
379
};
380

381
BOTAN_REGISTER_TEST("x509",
382
                    "x509_name_constraint_wildcard_excluded_containment",
383
                    Wildcard_Excluded_Subtree_Containment_Tests);
384

385
class SmtpUTF8Mailbox_Constraint_Match_Tests final : public Test {
1✔
386
   public:
387
      std::vector<Test::Result> run() override {
1✔
388
         Test::Result result("X509v3 Name Constraints: rfc822Name matches SmtpUTF8Mailbox");
1✔
389

390
         // RFC 9598 Section 6: rfc822Name constraints extend to SmtpUTF8Mailbox
391
         // SAN entries. The constraint's local-part (if any) is
392
         // ignored; comparison is on the domain part.
393

394
         const auto host_constraint = Botan::GeneralName::email("example.com");
1✔
395
         const auto subtree_constraint = Botan::GeneralName::email(".example.com");
1✔
396
         const auto mailbox_constraint = Botan::GeneralName::email("alice@example.com");
1✔
397

398
         const auto mailbox = [](std::string_view s) { return Botan::SmtpUtf8Mailbox::from_string(s).value(); };
30✔
399

400
         // Host constraint: domain must match exactly.
401
         result.test_is_true("host constraint matches identical domain",
2✔
402
                             host_constraint.matches_email(mailbox("user@example.com")));
1✔
403
         result.test_is_false("host constraint rejects subdomain",
2✔
404
                              host_constraint.matches_email(mailbox("user@sub.example.com")));
1✔
405
         result.test_is_false("host constraint rejects unrelated domain",
2✔
406
                              host_constraint.matches_email(mailbox("user@evil.com")));
1✔
407

408
         // Subtree constraint (leading dot): proper subdomains match,
409
         // base does not.
410
         result.test_is_true("subtree constraint matches subdomain",
2✔
411
                             subtree_constraint.matches_email(mailbox("user@sub.example.com")));
1✔
412
         result.test_is_false("subtree constraint rejects apex",
2✔
413
                              subtree_constraint.matches_email(mailbox("user@example.com")));
1✔
414
         result.test_is_false("subtree constraint rejects unrelated domain",
2✔
415
                              subtree_constraint.matches_email(mailbox("user@evil.com")));
1✔
416

417
         // RFC 9549 deprecates mailbox-form rfc822Name constraints for
418
         // SmtpUTF8Mailbox matching: such constraints must not match.
419
         result.test_is_false("mailbox constraint does not apply to SmtpUTF8Mailbox",
2✔
420
                              mailbox_constraint.matches_email(mailbox("alice@example.com")));
1✔
421

422
         // The reviewer's bypass: a CA permitted to ".example.com" issues
423
         // a leaf with SmtpUTF8Mailbox "alice@evil.com". Before the fix
424
         // this would slip through; after, the matcher correctly reports
425
         // no match, and is_excluded/is_permitted will reject the chain.
426
         result.test_is_false("bypass closed: ASCII evil.com against .example.com",
2✔
427
                              subtree_constraint.matches_email(mailbox("alice@evil.com")));
1✔
428
         result.test_is_false("bypass closed: UTF-8 local part doesn't change the answer",
2✔
429
                              subtree_constraint.matches_email(mailbox("\xCE\xB4\xCE\xBF\xCE\xBA\xCE\xB9@evil.com")));
1✔
430

431
         // Non-email constraints don't match regardless of mailbox.
432
         const auto dns_constraint = Botan::GeneralName::dns("example.com");
1✔
433
         result.test_is_false("DNS constraint doesn't match SmtpUTF8Mailbox",
2✔
434
                              dns_constraint.matches_email(mailbox("user@example.com")));
1✔
435

436
         // SmtpUtf8Mailbox::from_string rejects the malformed shapes
437
         // we previously had to guard against in the matcher.
438
         for(const auto& bad : {"",
23✔
439
                                "no-at-sign.example.com",
440
                                "@example.com",
441
                                "alice@",
442
                                "a@b@c",
443
                                "alice@.example.com",
444
                                "alice@example..com",
445
                                "alice..bob@example.com",
446
                                ".alice@example.com",
447
                                // RFC 9598 Section 3: non-ASCII domain labels
448
                                // MUST be in A-label form on the wire.
449
                                // Raw UTF-8 in the domain is rejected.
450
                                "alice@\xD0\xBF\xD1\x80\xD0\xB8\xD0\xBC\xD0\xB5\xD1\x80.\xD1\x80\xD1\x84",
451
                                // Invalid UTF-8 anywhere in the input.
452
                                "alice@\xC0\xC0.com"}) {
12✔
453
            result.test_is_false("SmtpUtf8Mailbox rejects malformed: " + std::string(bad),
22✔
454
                                 Botan::SmtpUtf8Mailbox::from_string(bad).has_value());
22✔
455
         }
456

457
         // ASCII and UTF-8-local-part mailboxes both parse.
458
         result.test_is_true("ASCII mailbox parses",
2✔
459
                             Botan::SmtpUtf8Mailbox::from_string("alice@example.com").has_value());
1✔
460
         result.test_is_true(
2✔
461
            "UTF-8 local part parses",
462
            Botan::SmtpUtf8Mailbox::from_string("\xCE\xB4\xCE\xBF\xCE\xBA\xCE\xB9@example.com").has_value());
1✔
463
         // A-label encoded IDN domain parses (RFC 9598 Section 3 mandates this
464
         // form for any label containing non-ASCII characters).
465
         result.test_is_true("A-label IDN domain parses",
2✔
466
                             Botan::SmtpUtf8Mailbox::from_string("alice@xn--e1afmkfd.xn--p1ai").has_value());
1✔
467

468
         return {result};
3✔
469
      }
5✔
470
};
471

472
BOTAN_REGISTER_TEST("x509", "x509_name_constraint_smtp_utf8_match", SmtpUTF8Mailbox_Constraint_Match_Tests);
473

474
class Name_Constraint_Encoding_Tests final : public Test {
1✔
475
   private:
476
      static std::vector<uint8_t> der_encode_name(const Botan::GeneralName& gn) {
8✔
477
         std::vector<uint8_t> der;
8✔
478
         Botan::DER_Encoder enc(der);
8✔
479
         enc.encode(gn);
8✔
480
         return der;
8✔
481
      }
8✔
482

483
      static Test::Result test_general_name_golden_bytes() {
1✔
484
         Test::Result result("X509v3 Name Constraints: GeneralName encoding");
1✔
485

486
         result.test_bin_eq(
2✔
487
            "dNSName host", der_encode_name(Botan::GeneralName::dns("example.com")), "820B6578616D706C652E636F6D");
2✔
488
         result.test_bin_eq("dNSName subtree",
2✔
489
                            der_encode_name(Botan::GeneralName::dns(".example.com")),
2✔
490
                            "820C2E6578616D706C652E636F6D");
491
         result.test_bin_eq("rfc822Name mailbox",
2✔
492
                            der_encode_name(Botan::GeneralName::email("alice@example.com")),
2✔
493
                            "8111616C696365406578616D706C652E636F6D");
494
         result.test_bin_eq("uniformResourceIdentifier host",
2✔
495
                            der_encode_name(Botan::GeneralName::uri("host.example.com")),
2✔
496
                            "8610686F73742E6578616D706C652E636F6D");
497
         result.test_bin_eq(
2✔
498
            "iPAddress IPv4 subnet",
499
            der_encode_name(Botan::GeneralName::ipv4_address(Botan::IPv4Subnet::from_string("10.0.0.0/8").value())),
3✔
500
            "87080A000000FF000000");
501
         result.test_bin_eq(
2✔
502
            "iPAddress IPv4 host",
503
            der_encode_name(Botan::GeneralName::ipv4_address(Botan::IPv4Address::from_string("192.0.2.1").value())),
3✔
504
            "8708C0000201FFFFFFFF");
505
         result.test_bin_eq(
2✔
506
            "iPAddress IPv6 subnet",
507
            der_encode_name(Botan::GeneralName::ipv6_address(Botan::IPv6Subnet::from_string("2001:db8::/32").value())),
3✔
508
            "872020010DB8000000000000000000000000FFFFFFFF000000000000000000000000");
509

510
         Botan::X509_DN dn;
1✔
511
         dn.add_attribute("X520.Country", "US");
1✔
512
         result.test_bin_eq("directoryName",
2✔
513
                            der_encode_name(Botan::GeneralName::directory_name(dn)),
3✔
514
                            "A40F300D310B3009060355040613025553");
515

516
         return result;
1✔
517
      }
1✔
518

519
      static Test::Result test_extension_golden_bytes() {
1✔
520
         Test::Result result("X509v3 Name Constraints: extension encoding");
1✔
521

522
         std::vector<Botan::GeneralSubtree> permitted;
1✔
523
         permitted.emplace_back(Botan::GeneralName::dns("example.com"));
1✔
524
         std::vector<Botan::GeneralSubtree> excluded;
1✔
525
         excluded.emplace_back(Botan::GeneralName::dns("evil.example.com"));
1✔
526

527
         Botan::Extensions exts;
1✔
528
         exts.add(std::make_unique<Botan::Cert_Extension::Name_Constraints>(
2✔
529
                     Botan::NameConstraints(std::move(permitted), std::move(excluded))),
2✔
530
                  true);
531

532
         result.test_bin_eq("NameConstraints extension body",
2✔
533
                            exts.get_extension_bits(Botan::Cert_Extension::Name_Constraints::static_oid()),
2✔
534
                            "3027A00F300D820B6578616D706C652E636F6DA11430128210"
535
                            "6576696C2E6578616D706C652E636F6D");
536
         return result;
2✔
537
      }
1✔
538

539
      static Test::Result test_roundtrip() {
1✔
540
         Test::Result result("X509v3 Name Constraints: encode/decode round trip");
1✔
541

542
         Botan::X509_DN dn;
1✔
543
         dn.add_attribute("X520.Country", "US");
1✔
544
         dn.add_attribute("X520.Organization", "Example Corp");
1✔
545

546
         std::vector<Botan::GeneralSubtree> permitted;
1✔
547
         permitted.emplace_back(Botan::GeneralName::dns("example.com"));
1✔
548
         permitted.emplace_back(Botan::GeneralName::dns(".sub.example.com"));
1✔
549
         permitted.emplace_back(Botan::GeneralName::email("alice@example.com"));
1✔
550
         permitted.emplace_back(Botan::GeneralName::email(".mail.example.com"));
1✔
551
         permitted.emplace_back(Botan::GeneralName::uri("host.example.com"));
1✔
552
         permitted.emplace_back(Botan::GeneralName::ipv4_address(Botan::IPv4Subnet::from_string("10.0.0.0/8").value()));
2✔
553
         permitted.emplace_back(
2✔
554
            Botan::GeneralName::ipv6_address(Botan::IPv6Subnet::from_string("2001:db8::/32").value()));
2✔
555
         permitted.emplace_back(Botan::GeneralName::directory_name(dn));
2✔
556

557
         std::vector<Botan::GeneralSubtree> excluded;
1✔
558
         excluded.emplace_back(Botan::GeneralName::dns("evil.example.com"));
1✔
559
         excluded.emplace_back(Botan::GeneralName::ipv4_address(Botan::IPv4Address::from_string("192.0.2.1").value()));
2✔
560
         excluded.emplace_back(Botan::GeneralName::ipv6_address(Botan::IPv6Address::from_string("::1").value()));
2✔
561

562
         const auto expected_permitted = describe(permitted);
1✔
563
         const auto expected_excluded = describe(excluded);
1✔
564

565
         Botan::Extensions exts;
1✔
566
         exts.add(std::make_unique<Botan::Cert_Extension::Name_Constraints>(
2✔
567
                     Botan::NameConstraints(std::move(permitted), std::move(excluded))),
2✔
568
                  true);
569

570
         const auto oid = Botan::Cert_Extension::Name_Constraints::static_oid();
1✔
571
         const auto first_encoding = exts.get_extension_bits(oid);
1✔
572

573
         std::vector<uint8_t> wire;
1✔
574
         // Extensions::encode_into skips the outer SEQUENCE
575
         Botan::DER_Encoder(wire).start_sequence().encode(exts).end_cons();
2✔
576
         Botan::Extensions parsed;
1✔
577
         Botan::BER_Decoder dec(wire);
1✔
578
         parsed.decode_from(dec, Botan::Extension_Context::Certificate);
1✔
579

580
         const auto* nc = parsed.get_extension_object_as<Botan::Cert_Extension::Name_Constraints>();
1✔
581
         if(!result.test_not_null("NameConstraints decoded as typed extension", nc)) {
1✔
582
            return result;
583
         }
584

585
         const auto& decoded = nc->get_name_constraints();
1✔
586
         result.test_is_true("permitted subtrees survive round trip",
1✔
587
                             describe(decoded.permitted()) == expected_permitted);
2✔
588
         result.test_is_true("excluded subtrees survive round trip", describe(decoded.excluded()) == expected_excluded);
2✔
589

590
         Botan::Extensions reencoded;
1✔
591
         reencoded.add(nc->copy(), true);
1✔
592
         result.test_bin_eq("re-encoding is byte identical", reencoded.get_extension_bits(oid), first_encoding);
1✔
593

594
         return result;
1✔
595
      }
3✔
596

597
      static Test::Result test_empty_encode_rejected() {
1✔
598
         Test::Result result("X509v3 Name Constraints: encoder rejects empty NameConstraints");
1✔
599
         Botan::Extensions exts;
1✔
600
         result.test_throws("Extensions::add throws on empty NameConstraints",
1✔
601
                            [&] { exts.add(std::make_unique<Botan::Cert_Extension::Name_Constraints>(), true); });
2✔
602
         return result;
1✔
603
      }
1✔
604

605
      static Test::Result test_othername_encode_rejected() {
1✔
606
         Test::Result result("X509v3 Name Constraints: encoder rejects otherName constraint");
1✔
607

608
         // otherName [0]: type-id 1.2.3.4 with a [0] UTF8String "abc" value.
609
         // Decoding retains only the type tag, so re-encoding must refuse.
610
         const auto der = Botan::hex_decode("A00C06032A0304A0050C03616263");
1✔
611
         Botan::BER_Decoder dec(der, Botan::BER_Decoder::Limits::DER());
1✔
612
         Botan::GeneralName gn;
1✔
613
         gn.decode_from(dec);
1✔
614
         if(!result.test_is_true("decoded as otherName", gn.type_code() == Botan::GeneralName::NameType::Other)) {
1✔
615
            return result;
616
         }
617

618
         std::vector<Botan::GeneralSubtree> permitted;
1✔
619
         permitted.emplace_back(gn);
1✔
620
         Botan::Extensions exts;
1✔
621
         result.test_throws("Extensions::add throws on otherName constraint", [&] {
1✔
622
            exts.add(std::make_unique<Botan::Cert_Extension::Name_Constraints>(
2✔
623
                        Botan::NameConstraints(std::move(permitted), {})),
3✔
624
                     true);
625
         });
×
626
         return result;
1✔
627
      }
2✔
628

629
   public:
630
      std::vector<Test::Result> run() override {
1✔
631
         return {test_general_name_golden_bytes(),
1✔
632
                 test_extension_golden_bytes(),
633
                 test_roundtrip(),
634
                 test_empty_encode_rejected(),
635
                 test_othername_encode_rejected()};
6✔
636
      }
1✔
637
};
638

639
BOTAN_REGISTER_TEST("x509", "x509_name_constraint_encoding", Name_Constraint_Encoding_Tests);
640

641
/*
642
* Re-encode every NameConstraints extension in the validation corpus and
643
* require byte-identical output. Constraint forms whose decode does not
644
* retain a value (otherName, unrecognized tags) must instead be rejected
645
* at encode time.
646
*/
647
class Name_Constraint_Corpus_Reencode_Tests final : public Test {
1✔
648
   private:
649
      static bool has_unencodable_name(const std::vector<Botan::GeneralSubtree>& subtrees) {
20✔
650
         return std::any_of(subtrees.begin(), subtrees.end(), [](const Botan::GeneralSubtree& subtree) {
39✔
651
            const auto type = subtree.base().type_code();
19✔
652
            return type == Botan::GeneralName::NameType::Other || type == Botan::GeneralName::NameType::Unknown;
19✔
653
         });
20✔
654
      }
655

656
      // Decode a NameConstraints extension body via the Extensions wire form.
657
      // `parsed` must outlive the returned pointer.
658
      static const Botan::Cert_Extension::Name_Constraints* decode_nc_body(Botan::Extensions& parsed,
6✔
659
                                                                           const std::vector<uint8_t>& body) {
660
         std::vector<uint8_t> wire;
6✔
661
         Botan::DER_Encoder enc(wire);
6✔
662
         enc.start_sequence()
6✔
663
            .start_sequence()
6✔
664
            .encode(Botan::Cert_Extension::Name_Constraints::static_oid())
6✔
665
            .encode(true)
6✔
666
            .encode(body, Botan::ASN1_Type::OctetString)
12✔
667
            .end_cons()
6✔
668
            .end_cons();
6✔
669
         Botan::BER_Decoder dec(wire);
6✔
670
         parsed.decode_from(dec, Botan::Extension_Context::Certificate);
6✔
671
         return parsed.get_extension_object_as<Botan::Cert_Extension::Name_Constraints>();
12✔
672
      }
6✔
673

674
   public:
675
      std::vector<Test::Result> run() override {
1✔
676
         Test::Result result("X509v3 Name Constraints: corpus re-encode");
1✔
677

678
         /*
679
         * These chains deliberately carry constraint encodings that decoding
680
         * canonicalizes (uppercase IA5 hosts, host bits set inside a CIDR mask,
681
         * an explicitly encoded DEFAULT minimum). Re-encoding produces the
682
         * canonical form, so for them require semantic equality and encoding
683
         * stability rather than byte identity.
684
         */
685
         const std::set<std::string> canonicalized_by_decode = {
1✔
686
            "dns-case-insensitive-valid",
687
            "ia5-locale-independent-case-valid",
688
            "ipv4-name-constraint-non-canonical-cidr-valid",
689
            "name-constraints-explicit-minimum-zero-valid",
690
            "uri-host-case-insensitive-valid",
691
            "wildcard-excluded-case-insensitive-invalid",
692
         };
1✔
693

694
         const std::string base = "x509/name_constraints/";
1✔
695
         std::vector<std::string> files{"root"};
2✔
696
         for(const auto& entry : read_manifest(Test::data_file(base + "expected.txt"))) {
300✔
697
            files.push_back(entry.first);
299✔
698
         }
1✔
699

700
         const auto oid = Botan::Cert_Extension::Name_Constraints::static_oid();
1✔
701
         size_t reencoded_count = 0;
1✔
702

703
         for(const auto& file : files) {
301✔
704
            for(const auto& cert : load_chain(Test::data_file(base + file + ".pem"))) {
1,221✔
705
               const auto& exts = cert.v3_extensions();
621✔
706
               const auto* nc = exts.get_extension_object_as<Botan::Cert_Extension::Name_Constraints>();
621✔
707
               if(nc == nullptr) {
621✔
708
                  continue;
338✔
709
               }
710

711
               const auto original = exts.get_extension_bits(oid);
283✔
712
               const bool critical = exts.critical_extension_set(oid);
283✔
713

714
               try {
283✔
715
                  Botan::Extensions fresh;
283✔
716
                  fresh.add(nc->copy(), critical);
302✔
717
                  const auto reencoded = fresh.get_extension_bits(oid);
264✔
718
                  reencoded_count += 1;
264✔
719

720
                  if(canonicalized_by_decode.contains(file)) {
264✔
721
                     Botan::Extensions parsed;
6✔
722
                     const auto* nc2 = decode_nc_body(parsed, reencoded);
6✔
723
                     if(!result.test_not_null(file + " canonical re-encoding decodes", nc2)) {
6✔
724
                        continue;
×
725
                     }
726
                     const auto& before = nc->get_name_constraints();
6✔
727
                     const auto& after = nc2->get_name_constraints();
6✔
728
                     result.test_is_true(file + " canonical re-encoding is semantically equal",
6✔
729
                                         describe(after.permitted()) == describe(before.permitted()) &&
18✔
730
                                            describe(after.excluded()) == describe(before.excluded()));
18✔
731

732
                     Botan::Extensions again;
6✔
733
                     again.add(nc2->copy(), critical);
6✔
734
                     result.test_bin_eq(
12✔
735
                        file + " canonical re-encoding is stable", again.get_extension_bits(oid), reencoded);
12✔
736
                  } else {
12✔
737
                     result.test_bin_eq(file + " re-encodes byte identical", reencoded, original);
516✔
738
                  }
739
               } catch(const Botan::Encoding_Error&) {
547✔
740
                  const auto& decoded = nc->get_name_constraints();
19✔
741
                  result.test_is_true(
38✔
742
                     file + " encode rejected only for otherName/unknown constraint",
19✔
743
                     has_unencodable_name(decoded.permitted()) || has_unencodable_name(decoded.excluded()));
19✔
744
               }
19✔
745
            }
583✔
746
         }
747

748
         result.test_sz_gte("corpus provided name constraint extensions", reencoded_count, 100);
1✔
749

750
         return {result};
3✔
751
      }
4✔
752
};
753

754
BOTAN_REGISTER_TEST("x509", "x509_name_constraint_corpus_reencode", Name_Constraint_Corpus_Reencode_Tests);
755

756
#endif
757

758
}  // namespace
759

760
}  // namespace Botan_Tests
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc