diff --git a/doc/api_ref/contents.rst b/doc/api_ref/contents.rst index a75bd8f46c3..dd9101b122c 100644 --- a/doc/api_ref/contents.rst +++ b/doc/api_ref/contents.rst @@ -16,6 +16,7 @@ API Reference cipher_modes pubkey x509 + pkcs12 tls credentials_manager bigint diff --git a/doc/api_ref/pkcs12.rst b/doc/api_ref/pkcs12.rst new file mode 100644 index 00000000000..b75cbfca90a --- /dev/null +++ b/doc/api_ref/pkcs12.rst @@ -0,0 +1,252 @@ +PKCS#12 +======================================== + +PKCS#12 (also known as PFX) is a file format defined in :rfc:`7292` for +storing cryptographic objects - typically a private key and its associated +X.509 certificate chain - protected by a password. It is widely used for +importing and exporting credentials in TLS servers, browsers, and +certificate management tools. + +This API is defined in ``botan/pkcs12.h``. + +.. versionadded:: 3.13 + +PKCS12_Export_Options +----------------------------------------- + +.. cpp:class:: PKCS12_Export_Options + + Options controlling how a PKCS#12 file is generated. Construct with the + password (mandatory) and an optional friendly name; tweak individual + fields with the chainable ``with_*`` mutators, or pick a preset via the + static pseudo-constructors below. + + .. cpp:function:: explicit PKCS12_Export_Options(std::string_view password, \ + std::optional friendly_name = {}) + + Constructs an options object with modern defaults: PBES2-SHA256-AES256 + key encryption, SHA-256 MAC, 100 000 KDF iterations, certificates + stored unencrypted. + + .. cpp:function:: static PKCS12_Export_Options modern(std::string_view password, \ + std::optional friendly_name = {}) + + Pseudo-constructor for modern defaults (identical to the regular + constructor). Spelled out for clarity at call sites. + + .. cpp:function:: static PKCS12_Export_Options legacy_compat(std::string_view password, \ + std::optional friendly_name = {}) + + Pseudo-constructor for legacy-compatible defaults: PBE-SHA1-3DES key + encryption, SHA-1 MAC, 2 048 KDF iterations. Use when interoperability + with old software (Java keytool pre-2019, legacy OpenSSL releases, + pre-Windows-10) is required. + + .. cpp:function:: PKCS12_Export_Options& with_friendly_name(std::string name) + + Overrides the friendly name. If unset, the bundle-level friendly name + (see :cpp:func:`PKCS12::set_friendly_name`) is used. + + .. cpp:function:: PKCS12_Export_Options& with_iterations(size_t n) + + Sets the KDF iteration count. Values of 0 or above 1 000 000 + (``PKCS12_MAX_ITERATIONS``) cause an ``Invalid_Argument`` exception at + export time. + + .. cpp:function:: PKCS12_Export_Options& with_key_encryption_algo(std::string algo) + + Algorithm used to encrypt the private key (PKCS8ShroudedKeyBag). + Supported values: + + - ``"PBES2-SHA256-AES256"`` - modern (default) + - ``"PBES2-SHA256-AES128"`` - modern + - ``"PBE-SHA1-3DES"`` - legacy + - ``"PBE-SHA1-2DES"`` - legacy + + .. cpp:function:: PKCS12_Export_Options& with_cert_encryption_algo(std::string algo) + + Algorithm used to encrypt certificates. If the algo is empty (default), + certificates are stored unencrypted. A non-empty value requires a + non-empty password. + + .. cpp:function:: PKCS12_Export_Options& with_mac_digest(std::string algo) + + Hash algorithm for the integrity MAC. Supported: ``"SHA-1"``, + ``"SHA-224"``, ``"SHA-256"``, ``"SHA-384"``, ``"SHA-512"``, + ``"SHA-512-256"``. Default is ``"SHA-256"``. + + .. cpp:function:: PKCS12_Export_Options& without_mac() + + Disables the integrity MAC. Generally not recommended; required if the + password is empty. + +PKCS12 +----------------------------------------- + +.. cpp:class:: PKCS12 + + PKCS#12/PFX bundle: parse, inspect, mutate, and export. The default + constructor produces an empty bundle that the caller fills via the + ``add_*`` / ``set_*`` mutators before invoking :cpp:func:`export_to`. + + .. cpp:function:: PKCS12() + + Constructs an empty bundle. + + .. cpp:function:: PKCS12(std::span data, std::string_view password) + + Parses a DER-encoded PFX file. Throws ``Decoding_Error`` if the file + is malformed, or ``Invalid_Authentication_Tag`` if MAC verification + fails. + + Accessors + ^^^^^^^^^ + + .. cpp:function:: const std::vector>& private_keys() const + + Private keys stored in the bundle, in storage order (parse-order for + parsed PFX, insertion-order for built ones). PKCS#12 supports multiple + keys per file. + + .. cpp:function:: const std::vector& certificates() const + + All certificates stored in the bundle. The end-entity, if any, comes + first when produced by parsing; insertion order is preserved for + bundles built in memory. + + .. cpp:function:: std::optional end_entity_certificate() const + + The first certificate whose ``subjectPublicKeyInfo`` matches one of + the stored private keys, or ``nullopt`` if none match (e.g. a + certificate-only or key-only bundle). + + .. cpp:function:: std::vector ca_certificates() const + + Convenience helper: every certificate except the one returned by + :cpp:func:`end_entity_certificate`. Returned in storage order. For a + key-less bundle, returns all certificates after the first. + + .. cpp:function:: const std::optional& friendly_name() const + + The ``friendlyName`` attribute, if present. + + .. cpp:function:: const std::optional>& local_key_id() const + + The ``localKeyId`` attribute, if present. + + .. cpp:function:: const std::vector& unknown_bag_types() const + + OIDs of bag types encountered during parsing but not handled by this + implementation (e.g. ``SecretBag``). Empty for normal PKCS#12 files + and for bundles constructed in-memory. + + Mutators + ^^^^^^^^ + + .. cpp:function:: void add_key(std::shared_ptr key) + + Adds a private key to the bundle. + + .. cpp:function:: void add_certificate(X509_Certificate cert) + + Adds a certificate. End-entity vs CA is determined at export time by + matching against stored keys. + + .. cpp:function:: void set_friendly_name(std::string name) + .. cpp:function:: void clear_friendly_name() + .. cpp:function:: void set_local_key_id(std::vector id) + .. cpp:function:: void clear_local_key_id() + + Set or clear the bundle-level friendly name / localKeyId attributes. + + Export + ^^^^^^ + + .. cpp:function:: std::vector export_to(const PKCS12_Export_Options& options, \ + RandomNumberGenerator& rng) const + + Serializes the bundle as a PKCS#12/PFX file. + + Throws ``Invalid_Argument`` if the options are inconsistent (e.g. + unsupported algorithm, MAC enabled with empty password) or if a + stored private key does not match any stored certificate. + + Iteration counts above ``PKCS12_MAX_ITERATIONS`` (1 000 000) and a + SafeContentsBag nesting depth above ``PKCS12_MAX_NESTING`` (10) are + rejected. + +Examples +----------------------------------------- + +Generating a PFX file from a freshly created key and self-signed certificate: + +.. literalinclude:: /../src/examples/pkcs12_export.cpp + :language: cpp + +Parsing a PFX file with error handling: + +.. literalinclude:: /../src/examples/pkcs12_parse.cpp + :language: cpp + +Building a PFX bundle that contains a CA certificate chain alongside the +end-entity key and certificate: + +.. literalinclude:: /../src/examples/pkcs12_export_chain.cpp + :language: cpp + +.. note:: + + The default encryption algorithm is ``PBES2-SHA256-AES256`` with + ``SHA-256`` MAC and 100 000 KDF iterations. For maximum compatibility + with legacy software (older Java keytool, legacy OpenSSL builds), use + :cpp:func:`PKCS12_Export_Options::legacy_compat` or explicitly configure + ``with_key_encryption_algo("PBE-SHA1-3DES")``, + ``with_mac_digest("SHA-1")``, and ``with_iterations(2048)``. + +Supported Algorithms +----------------------------------------- + +The following algorithms are available depending on which Botan modules are built: + +.. list-table:: + :header-rows: 1 + :widths: 25 30 25 20 + + * - Field + - Value + - Required module + - Notes + * - ``with_key_encryption_algo`` + - ``"PBES2-SHA256-AES256"`` + - ``pbes2``, ``aes`` + - Default; recommended for modern use + * - ``with_key_encryption_algo`` + - ``"PBES2-SHA256-AES128"`` + - ``pbes2``, ``aes`` + - Modern + * - ``with_key_encryption_algo`` + - ``"PBE-SHA1-3DES"`` + - ``pkcs12_pbe``, ``des`` + - Legacy; widest compatibility + * - ``with_key_encryption_algo`` + - ``"PBE-SHA1-2DES"`` + - ``pkcs12_pbe``, ``des`` + - Legacy + * - ``with_cert_encryption_algo`` + - Same as above, or ``""`` + - - + - Empty = certificates stored unencrypted + * - ``with_mac_digest`` + - ``"SHA-256"`` + - ``sha2_32`` + - Default; required by OpenSSL 3.x default policy + * - ``with_mac_digest`` + - ``"SHA-1"`` + - ``sha1`` + - Legacy; widest compatibility + * - ``with_mac_digest`` + - ``"SHA-384"``, ``"SHA-512"`` + - ``sha2_64`` + - Uncommon; supported for parsing and generation + +See :doc:`/cli` for the ``pkcs12_export`` / ``pkcs12_info`` CLI commands. diff --git a/doc/cli.rst b/doc/cli.rst index 0241753f3b6..4440f4fc092 100644 --- a/doc/cli.rst +++ b/doc/cli.rst @@ -218,6 +218,36 @@ X.509 ``trust_roots --dn --dn-only --display`` List the certificates in the system trust store. +PKCS#12 +---------------------------------------------- + +``pkcs12_export --output= --pass= --in-key-pass= --friendly-name= --key-cipher=PBES2-SHA256-AES256 --no-mac --cert-cipher= --mac-digest=SHA-256 --iterations=100000 key cert *ca_certs`` + Bundle a PKCS#8 *key*, its end-entity *cert*, and optionally one or more CA + certificates (*ca_certs*) into a PKCS#12/PFX file. The PFX password is + taken from ``--pass``; if the input key is itself encrypted, its passphrase + is taken from ``--in-key-pass``. + + ``--key-cipher`` selects the private-key encryption algorithm. Modern + choices (default): ``"PBES2-SHA256-AES256"``, ``"PBES2-SHA256-AES128"``. + Legacy interop: ``"PBE-SHA1-3DES"``, ``"PBE-SHA1-2DES"``. + ``--cert-cipher`` (empty by default) wraps the certificate bags in an + EncryptedData; leave empty for unencrypted certificates. + ``--mac-digest`` selects the integrity-MAC digest (``SHA-256`` default, + ``SHA-1`` for maximum interoperability). ``--no-mac`` omits the MAC + entirely (required when ``--pass`` is empty). + +``pkcs12_info --pass= --key-out= --cert-out= --chain-out= --out-key-pass= --out-key-cipher= --key-pbkdf-iter=100000 pfx_file`` + Inspect a PKCS#12/PFX file. Without output arguments, prints a summary of + the contents (key algorithm, end-entity subject/issuer/serial/validity, + SHA-1 and SHA-256 fingerprints, CA chain, friendly name). + + ``--key-out`` writes the extracted private key as PEM. When + ``--out-key-pass`` is set the key is re-encrypted with the algorithm from + ``--out-key-cipher`` (default ``AES-256/CBC``) using ``--key-pbkdf-iter`` + PBKDF iterations. ``--cert-out`` writes the end-entity certificate as PEM, + and ``--chain-out`` writes the CA certificate chain (one PEM block per + cert) if any are present. + TLS Server/Client ----------------------- diff --git a/src/cli/pkcs12.cpp b/src/cli/pkcs12.cpp new file mode 100644 index 00000000000..de13b492b1c --- /dev/null +++ b/src/cli/pkcs12.cpp @@ -0,0 +1,211 @@ +/* +* PKCS#12 CLI +* (C) 2026 Damiano Mazzella +* +* Botan is released under the Simplified BSD License (see license.txt) +*/ + +#include "cli.h" + +#if defined(BOTAN_HAS_PKCS12) && defined(BOTAN_TARGET_OS_HAS_FILESYSTEM) + + #include + #include + #include + #include + #include + #include + #include + #include + #include + +namespace Botan_CLI { + +class PKCS12_Export final : public Command { + public: + PKCS12_Export() : + Command( + "pkcs12_export --output= --pass= --in-key-pass= --friendly-name= --key-cipher=PBES2-SHA256-AES256 --no-mac --cert-cipher= --mac-digest=SHA-256 --iterations=100000 key cert *ca_certs") { + } + + std::string group() const override { return "pkcs12"; } + + std::string description() const override { return "Export private key and certificate(s) to PKCS#12/PFX format"; } + + void go() override { + const std::string key_file = get_arg("key"); + const std::string key_pass = get_passphrase_arg("Key file password (empty if unencrypted)", "in-key-pass"); + const std::string pfx_pass = get_passphrase_arg("PFX password", "pass"); + + // Load private key + Botan::DataSource_Stream key_stream(key_file); + auto private_key = Botan::PKCS8::load_key(key_stream, key_pass); + if(!private_key) { + throw CLI_Error("Failed to load private key from " + key_file); + } + + // Load end-entity certificate + const Botan::X509_Certificate cert(get_arg("cert")); + + // Populate the bundle + Botan::PKCS12 bundle; + bundle.add_key(std::shared_ptr(std::move(private_key))); + bundle.add_certificate(cert); + for(const auto& ca_file : get_arg_list("ca_certs")) { + bundle.add_certificate(Botan::X509_Certificate(ca_file)); + } + + const std::string friendly_name = get_arg("friendly-name"); + if(!friendly_name.empty()) { + bundle.set_friendly_name(friendly_name); + } + + // Build export options + Botan::PKCS12_Export_Options options(pfx_pass); + options.with_iterations(get_arg_sz("iterations")); + options.with_key_encryption_algo(get_arg("key-cipher")); + + const std::string cert_cipher = get_arg("cert-cipher"); + if(!cert_cipher.empty()) { + options.with_cert_encryption_algo(cert_cipher); + } + + if(flag_set("no-mac")) { + options.without_mac(); + } + + const std::string mac_digest = get_arg("mac-digest"); + if(!mac_digest.empty()) { + options.with_mac_digest(mac_digest); + } + + const auto pfx_data = bundle.export_to(options, rng()); + + write_output(pfx_data); + } +}; + +BOTAN_REGISTER_COMMAND("pkcs12_export", PKCS12_Export); + +class PKCS12_Info final : public Command { + public: + PKCS12_Info() : + Command( + "pkcs12_info --pass= --key-out= --cert-out= --chain-out= --out-key-pass= --out-key-cipher= --key-pbkdf-iter=100000 pfx_file") { + } + + std::string group() const override { return "pkcs12"; } + + std::string description() const override { + return "Inspect a PKCS#12/PFX file (and optionally extract key and certificates)"; + } + + void go() override { + const std::string pfx_file = get_arg("pfx_file"); + const std::string pfx_pass = get_passphrase_arg("PFX password", "pass"); + + std::vector pfx_data = slurp_file(pfx_file); + + const Botan::PKCS12 bundle(pfx_data, pfx_pass); + + const bool has_key = !bundle.private_keys().empty(); + const auto end_entity = bundle.end_entity_certificate(); + + // Build CA chain list (everything except end-entity) + const std::vector ca_chain = bundle.ca_certificates(); + + // Output private key + const std::string key_out = get_arg("key-out"); + if(!key_out.empty() && !has_key) { + output() << "Warning: --key-out specified but PFX contains no private key\n"; + } + if(!key_out.empty() && has_key) { + const std::string key_pass = + get_passphrase_arg("Output key password (empty for unencrypted)", "out-key-pass"); + std::ofstream key_stream(key_out); + if(!key_stream) { + throw CLI_Error("Cannot open " + key_out + " for writing"); + } + + const auto& key = *bundle.private_keys().front(); + if(key_pass.empty()) { + key_stream << Botan::PKCS8::PEM_encode(key); + } else { + const std::string cipher = get_arg_or("out-key-cipher", "AES-256/CBC"); + const size_t iterations = get_arg_sz("key-pbkdf-iter"); + key_stream << Botan::PKCS8::PEM_encode_encrypted_pbkdf_iter(key, rng(), key_pass, iterations, cipher); + } + output() << "Private key written to " << key_out << "\n"; + } + + // Output end-entity certificate + const std::string cert_out = get_arg("cert-out"); + if(!cert_out.empty() && !end_entity.has_value()) { + output() << "Warning: --cert-out specified but PFX contains no end-entity certificate\n"; + } + if(!cert_out.empty() && end_entity.has_value()) { + std::ofstream cert_stream(cert_out); + if(!cert_stream) { + throw CLI_Error("Cannot open " + cert_out + " for writing"); + } + cert_stream << end_entity->PEM_encode(); + output() << "Certificate written to " << cert_out << "\n"; + } + + // Output CA chain + const std::string chain_out = get_arg("chain-out"); + if(!chain_out.empty() && ca_chain.empty()) { + output() << "Warning: --chain-out specified but PFX contains no CA certificates\n"; + } + if(!chain_out.empty() && !ca_chain.empty()) { + std::ofstream chain_stream(chain_out); + if(!chain_stream) { + throw CLI_Error("Cannot open " + chain_out + " for writing"); + } + for(const auto& ca_cert : ca_chain) { + chain_stream << ca_cert.PEM_encode(); + } + output() << "CA chain (" << ca_chain.size() << " certs) written to " << chain_out << "\n"; + } + + // If no output files specified, show info + if(key_out.empty() && cert_out.empty() && chain_out.empty()) { + output() << "PKCS#12 contents:\n\n"; + if(has_key) { + const auto& key = *bundle.private_keys().front(); + output() << "Private Key:\n"; + output() << " Algorithm: " << key.algo_name() << "\n"; + output() << " Key Size: " << key.key_length() << " bits\n\n"; + } + if(end_entity.has_value()) { + output() << "End-Entity Certificate:\n"; + output() << " Subject: " << end_entity->subject_dn().to_string() << "\n"; + output() << " Issuer: " << end_entity->issuer_dn().to_string() << "\n"; + output() << " Serial: " << Botan::hex_encode(end_entity->serial_number()) << "\n"; + output() << " Not Before: " << end_entity->not_before().readable_string() << "\n"; + output() << " Not After: " << end_entity->not_after().readable_string() << "\n"; + output() << " SHA-1 Fingerprint: " << end_entity->fingerprint("SHA-1") << "\n"; + output() << " SHA-256 Fingerprint: " << end_entity->fingerprint("SHA-256") << "\n\n"; + } + if(!ca_chain.empty()) { + output() << "CA Certificate Chain (" << ca_chain.size() << " certificates):\n"; + size_t idx = 1; + for(const auto& ca_cert : ca_chain) { + output() << " [" << idx++ << "] Subject: " << ca_cert.subject_dn().to_string() << "\n"; + output() << " Issuer: " << ca_cert.issuer_dn().to_string() << "\n"; + output() << " Not After: " << ca_cert.not_after().readable_string() << "\n"; + } + output() << "\n"; + } + if(bundle.friendly_name().has_value()) { + output() << "Friendly name: " << *bundle.friendly_name() << "\n"; + } + } + } +}; + +BOTAN_REGISTER_COMMAND("pkcs12_info", PKCS12_Info); + +} // namespace Botan_CLI + +#endif diff --git a/src/examples/pkcs12_export.cpp b/src/examples/pkcs12_export.cpp new file mode 100644 index 00000000000..6a007861530 --- /dev/null +++ b/src/examples/pkcs12_export.cpp @@ -0,0 +1,33 @@ +#include +#include +#include +#include +#include +#include + +#include +#include + +int main() { + Botan::AutoSeeded_RNG rng; + + // Generate an ECDSA private key + self-signed certificate to bundle. + auto key = std::make_shared(rng, Botan::EC_Group::from_name("secp256r1")); + + const Botan::X509_Cert_Options cert_opts("example.com"); + const auto cert = Botan::X509::create_self_signed_cert(cert_opts, *key, "SHA-256", rng); + + // Populate the PKCS#12 bundle. + Botan::PKCS12 bundle; + bundle.add_key(key); + bundle.add_certificate(cert); + bundle.set_friendly_name("My Key"); + + // Export with modern defaults (PBES2-SHA256-AES256, SHA-256 MAC, + // 100 000 iterations). For maximum interoperability with legacy software + // use Botan::PKCS12_Export_Options::legacy_compat("secret") instead. + const auto pfx = bundle.export_to(Botan::PKCS12_Export_Options::modern("secret"), rng); + + std::cout << Botan::hex_encode(pfx) << '\n'; + return 0; +} diff --git a/src/examples/pkcs12_export_chain.cpp b/src/examples/pkcs12_export_chain.cpp new file mode 100644 index 00000000000..565afe2e3f2 --- /dev/null +++ b/src/examples/pkcs12_export_chain.cpp @@ -0,0 +1,43 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +int main() { + Botan::AutoSeeded_RNG rng; + const auto group = Botan::EC_Group::from_name("secp256r1"); + + // Issuing CA. + const Botan::ECDSA_PrivateKey ca_key(rng, group); + Botan::X509_Cert_Options ca_opts("Example CA"); + ca_opts.CA_key(); + const auto ca_cert = Botan::X509::create_self_signed_cert(ca_opts, ca_key, "SHA-256", rng); + + // End-entity, signed by the CA. + auto ee_key = std::make_shared(rng, group); + Botan::X509_Cert_Options ee_opts("example.com"); + ee_opts.dns = "example.com"; + const auto csr = Botan::X509::create_cert_req(ee_opts, *ee_key, "SHA-256", rng); + const Botan::X509_CA ca(ca_cert, ca_key, "SHA-256", rng); + const auto ee_cert = ca.sign_request(csr, rng, Botan::X509_Time("200101000000Z"), Botan::X509_Time("300101000000Z")); + + // Bundle: end-entity key + cert + issuing CA in the chain. + Botan::PKCS12 bundle; + bundle.add_key(ee_key); + bundle.add_certificate(ee_cert); + bundle.add_certificate(ca_cert); + + const auto pfx = bundle.export_to(Botan::PKCS12_Export_Options::modern("secret", "Server Key"), rng); + + std::cout << Botan::hex_encode(pfx) << '\n'; + return 0; +} diff --git a/src/examples/pkcs12_parse.cpp b/src/examples/pkcs12_parse.cpp new file mode 100644 index 00000000000..925b22099ef --- /dev/null +++ b/src/examples/pkcs12_parse.cpp @@ -0,0 +1,45 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +int main() { + // Read a hex-encoded PFX from stdin. + const std::string hex_input((std::istreambuf_iterator(std::cin)), std::istreambuf_iterator()); + const auto pfx_bytes = Botan::hex_decode(hex_input); + + try { + const Botan::PKCS12 bundle(pfx_bytes, "secret"); + + if(!bundle.private_keys().empty()) { + const auto& key = bundle.private_keys().front(); + std::cout << "Key: " << key->algo_name() << " (" << key->key_length() << " bits)\n"; + } + + if(const auto ee = bundle.end_entity_certificate()) { + std::cout << "End-entity: " << ee->subject_dn().to_string() << '\n' + << "Fingerprint (SHA-256): " << ee->fingerprint("SHA-256") << '\n'; + } + + for(const auto& ca : bundle.ca_certificates()) { + std::cout << "CA: " << ca.subject_dn().to_string() << '\n'; + } + + if(bundle.friendly_name()) { + std::cout << "Friendly name: " << *bundle.friendly_name() << '\n'; + } + } catch(const Botan::Invalid_Authentication_Tag&) { + std::cerr << "Wrong password or corrupted MAC\n"; + return 1; + } catch(const Botan::Decoding_Error& e) { + std::cerr << "Malformed or unsupported PFX file: " << e.what() << '\n'; + return 2; + } + return 0; +} diff --git a/src/lib/pbkdf/pkcs12_kdf/pkcs12_kdf.cpp b/src/lib/pbkdf/pkcs12_kdf/pkcs12_kdf.cpp index 0a4bf8606c2..409d59a5612 100644 --- a/src/lib/pbkdf/pkcs12_kdf/pkcs12_kdf.cpp +++ b/src/lib/pbkdf/pkcs12_kdf/pkcs12_kdf.cpp @@ -143,6 +143,15 @@ void pkcs12_kdf_with_hash(std::span out, } // namespace +void pkcs12_kdf(std::span out, + std::span pwd_bytes, + std::span salt, + size_t iterations, + uint8_t id, + HashFunction& hash) { + pkcs12_kdf_with_hash(out, pwd_bytes, salt, iterations, id, hash); +} + PKCS12_KDF::PKCS12_KDF(std::unique_ptr hash, uint8_t id, size_t iterations) : m_hash(std::move(hash)), m_id(id), m_iterations(iterations) { BOTAN_ARG_CHECK(m_hash != nullptr, "PKCS12-KDF: hash must not be null"); diff --git a/src/lib/pbkdf/pkcs12_kdf/pkcs12_kdf.h b/src/lib/pbkdf/pkcs12_kdf/pkcs12_kdf.h index 09b61db659f..906dbb1f2e2 100644 --- a/src/lib/pbkdf/pkcs12_kdf/pkcs12_kdf.h +++ b/src/lib/pbkdf/pkcs12_kdf/pkcs12_kdf.h @@ -13,6 +13,7 @@ #include #include +#include #include #include @@ -52,6 +53,22 @@ class PKCS12_KDF final : public PasswordHash { size_t m_iterations; }; +/** +* Low-level PKCS#12 KDF (RFC 7292 Appendix B). +* +* Runs the KDF on a caller-supplied @p pwd_bytes buffer without applying +* @c pkcs12_encode_password. Intended for the very rare case (e.g. OpenSSL +* empty-password interop) where the RFC 7292 UCS-2 BE encoding + null +* terminator is not the desired input. Normal callers should use the +* PKCS12_KDF class. +*/ +BOTAN_TEST_API void pkcs12_kdf(std::span out, + std::span pwd_bytes, + std::span salt, + size_t iterations, + uint8_t id, + HashFunction& hash); + class PKCS12_KDF_Family final : public PasswordHashFamily { public: PKCS12_KDF_Family(std::unique_ptr hash, size_t id); diff --git a/src/lib/pkcs12/info.txt b/src/lib/pkcs12/info.txt new file mode 100644 index 00000000000..78fb4f50f85 --- /dev/null +++ b/src/lib/pkcs12/info.txt @@ -0,0 +1,22 @@ + +PKCS12 -> 20260305 + + + +name -> "PKCS#12" +brief -> "PKCS#12/PFX certificate and key bundle format" + + + +asn1 +x509 +pubkey +pkcs12_pbe +hmac +sha1 +sha2_32 + + + +pkcs12.h + \ No newline at end of file diff --git a/src/lib/pkcs12/pkcs12.cpp b/src/lib/pkcs12/pkcs12.cpp new file mode 100644 index 00000000000..e84558bef47 --- /dev/null +++ b/src/lib/pkcs12/pkcs12.cpp @@ -0,0 +1,938 @@ +/* +* PKCS#12 +* (C) 2026 Damiano Mazzella +* +* Botan is released under the Simplified BSD License (see license.txt) +*/ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Botan { + +namespace { + +// Associates a parsed certificate with its bag attributes +struct ParsedCert { + X509_Certificate cert; + std::vector local_key_id; + std::string friendly_name; +}; + +/* +* Encode a friendly name as a BMPString (UTF-16BE) +* ASN1_String doesn't support encoding BMPStrings, so we do it manually +*/ +void encode_bmpstring(DER_Encoder& enc, std::string_view str) { + const std::vector utf16be = utf8_to_ucs2(str); + enc.add_object(ASN1_Type::BmpString, ASN1_Class::Universal, utf16be); +} + +/* +* Resolve a MAC digest OID to a hash name, throwing on unsupported algorithms. +*/ +std::string resolve_mac_hash(const OID& oid) { + if(oid == OID::from_string("SHA-1")) { + return "SHA-1"; + } + if(oid == OID::from_string("SHA-224")) { + return "SHA-224"; + } + if(oid == OID::from_string("SHA-256")) { + return "SHA-256"; + } + if(oid == OID::from_string("SHA-384")) { + return "SHA-384"; + } + if(oid == OID::from_string("SHA-512")) { + return "SHA-512"; + } + if(oid == OID::from_string("SHA-512-256")) { + return "SHA-512-256"; + } + throw Decoding_Error(fmt("Unsupported PKCS#12 MAC digest: {}", oid.to_formatted_string())); +} + +/* +* Validate PKCS12_Export_Options before starting export. +*/ +void validate_options(const PKCS12_Export_Options& opts) { + if(opts.iterations() == 0 || opts.iterations() > PKCS12_MAX_ITERATIONS) { + throw Invalid_Argument(fmt("PKCS#12: iteration count must be between 1 and {}", PKCS12_MAX_ITERATIONS)); + } + static const std::array supported_key_algos = { + "PBE-SHA1-3DES", + "PBE-SHA1-2DES", + "PBES2-SHA256-AES256", + "PBES2-SHA256-AES128", + }; + if(std::find(supported_key_algos.begin(), supported_key_algos.end(), opts.key_encryption_algo()) == + supported_key_algos.end()) { + throw Invalid_Argument(fmt("PKCS#12: unsupported key encryption algorithm '{}'", opts.key_encryption_algo())); + } + if(!opts.cert_encryption_algo().empty()) { + if(std::find(supported_key_algos.begin(), supported_key_algos.end(), opts.cert_encryption_algo()) == + supported_key_algos.end()) { + throw Invalid_Argument( + fmt("PKCS#12: unsupported cert encryption algorithm '{}'", opts.cert_encryption_algo())); + } + if(opts.password().empty()) { + throw Invalid_Argument("PKCS#12: cert_encryption_algo requires a non-empty password"); + } + } + if(opts.include_mac()) { + if(opts.password().empty()) { + throw Invalid_Argument("PKCS#12: include_mac requires a non-empty password"); + } + static const std::array supported_mac_digests = { + "SHA-1", "SHA-224", "SHA-256", "SHA-384", "SHA-512", "SHA-512-256"}; + if(std::find(supported_mac_digests.begin(), supported_mac_digests.end(), opts.mac_digest()) == + supported_mac_digests.end()) { + throw Invalid_Argument(fmt("PKCS#12: unsupported MAC digest '{}'", opts.mac_digest())); + } + if(!HashFunction::create(opts.mac_digest())) { + throw Invalid_Argument(fmt("PKCS#12: MAC digest '{}' is not available in this build", opts.mac_digest())); + } + } +} + +/* +* Verify PKCS#12 MAC. +* +* When @p openssl_empty_pwd_compat is @c true and @p password is empty, the +* KDF is fed an empty byte string (OpenSSL non-conforming behavior) instead +* of the RFC 7292 form (a two-byte {0x00,0x00} terminator). +*/ +void verify_mac(std::span auth_safe_data, + std::span mac_value, + std::span mac_salt, + size_t iterations, + const std::string& hash_name, + std::string_view password, + bool openssl_empty_pwd_compat) { + auto hmac = MessageAuthenticationCode::create_or_throw(fmt("HMAC({})", hash_name)); + const size_t mac_key_len = hmac->output_length(); + + secure_vector mac_key(mac_key_len); + if(openssl_empty_pwd_compat && password.empty()) { + const auto hash = HashFunction::create_or_throw(hash_name); + pkcs12_kdf({mac_key.data(), mac_key_len}, {}, {mac_salt.data(), mac_salt.size()}, iterations, 3, *hash); + } else { + const PKCS12_KDF kdf(HashFunction::create_or_throw(hash_name), 3, iterations); + kdf.derive_key(mac_key.data(), mac_key_len, password.data(), password.size(), mac_salt.data(), mac_salt.size()); + } + + hmac->set_key(mac_key); + hmac->update(auth_safe_data); + if(!constant_time_compare(hmac->final(), mac_value)) { + throw Invalid_Authentication_Tag("PKCS#12 MAC verification failed"); + } +} + +/* +* Parse attributes from a SafeBag. Only FriendlyName and LocalKeyId are +* handled; other attributes are silently skipped (RFC 7292 sec.4.2). +*/ +void parse_bag_attributes(BER_Decoder& decoder, std::string& friendly_name, std::vector& local_key_id) { + if(!decoder.more_items()) { + return; + } + + const OID friendly_name_oid = OID::from_string("PKCS9.FriendlyName"); + const OID local_key_id_oid = OID::from_string("PKCS9.LocalKeyId"); + + BER_Decoder attrs = decoder.start_set(); + while(attrs.more_items()) { + OID attr_oid; + BER_Decoder attr_seq = attrs.start_sequence(); + attr_seq.decode(attr_oid); + + BER_Decoder values = attr_seq.start_set(); + if(attr_oid == friendly_name_oid) { + ASN1_String str; + values.decode(str); + friendly_name = str.value(); + } else if(attr_oid == local_key_id_oid) { + values.decode(local_key_id, ASN1_Type::OctetString); + } + values.discard_remaining(); + values.end_cons(); + attr_seq.discard_remaining(); + attr_seq.end_cons(); + } + attrs.end_cons(); +} + +// Helper bag carrying a parsed private key with its attributes. +struct ParsedKey { + std::shared_ptr key; + std::vector local_key_id; + std::string friendly_name; +}; + +/* +* Parse SafeContents (sequence of SafeBag) +*/ +void parse_safe_contents(BER_Decoder& decoder, + std::string_view password, + std::vector& cert_entries, + std::vector& key_entries, + std::vector& unknown_bag_types, + bool openssl_empty_pwd_compat, + size_t depth = 0) { + if(depth >= PKCS12_MAX_NESTING) { + throw Decoding_Error("PKCS#12: SafeContentsBag nesting too deep"); + } + const OID cert_bag_oid = OID::from_string("PKCS12.CertBag"); + const OID shrouded_key_bag_oid = OID::from_string("PKCS12.PKCS8ShroudedKeyBag"); + const OID key_bag_oid = OID::from_string("PKCS12.KeyBag"); + const OID safe_contents_bag_oid = OID::from_string("PKCS12.SafeContentsBag"); + const OID x509_cert_oid = OID::from_string("PKCS9.X509Certificate"); + + while(decoder.more_items()) { + OID bag_type; + std::string bag_friendly_name; + std::vector bag_key_id; + + BER_Decoder bag_seq = decoder.start_sequence(); + bag_seq.decode(bag_type); + + BER_Decoder bag_value = bag_seq.start_context_specific(0); + + bool pushed_cert = false; + bool pushed_key = false; + + if(bag_type == cert_bag_oid) { + OID cert_type; + BER_Decoder cert_bag = bag_value.start_sequence(); + cert_bag.decode(cert_type); + + if(cert_type == x509_cert_oid) { + std::vector cert_data; + BER_Decoder cert_value = cert_bag.start_context_specific(0); + cert_value.decode(cert_data, ASN1_Type::OctetString); + cert_value.verify_end(); + + cert_entries.push_back({X509_Certificate(cert_data), {}, {}}); + pushed_cert = true; + } else { + cert_bag.discard_remaining(); + } + cert_bag.end_cons(); + bag_value.verify_end(); + } else if(bag_type == shrouded_key_bag_oid) { + AlgorithmIdentifier pbe_algo; + std::vector encrypted_key; + + BER_Decoder shrouded = bag_value.start_sequence(); + shrouded.decode(pbe_algo); + shrouded.decode(encrypted_key, ASN1_Type::OctetString); + shrouded.verify_end(); + + auto decrypted = pkcs12_pbe_decrypt(encrypted_key, password, pbe_algo, openssl_empty_pwd_compat); + DataSource_Memory src(decrypted); + key_entries.push_back({std::shared_ptr(PKCS8::load_key(src)), {}, {}}); + pushed_key = true; + } else if(bag_type == key_bag_oid) { + secure_vector key_data; + bag_value.raw_bytes(key_data); + bag_value.verify_end(); + DataSource_Memory src(key_data); + key_entries.push_back({std::shared_ptr(PKCS8::load_key(src)), {}, {}}); + pushed_key = true; + } else if(bag_type == safe_contents_bag_oid) { + BER_Decoder nested_sc = bag_value.start_sequence(); + parse_safe_contents( + nested_sc, password, cert_entries, key_entries, unknown_bag_types, openssl_empty_pwd_compat, depth + 1); + nested_sc.verify_end(); + bag_value.verify_end(); + } else { + unknown_bag_types.push_back(bag_type); + bag_value.discard_remaining(); + } + + bag_value.end_cons(); + + parse_bag_attributes(bag_seq, bag_friendly_name, bag_key_id); + + if(pushed_cert && !cert_entries.empty()) { + if(!bag_key_id.empty()) { + cert_entries.back().local_key_id = bag_key_id; + } + if(!bag_friendly_name.empty()) { + cert_entries.back().friendly_name = bag_friendly_name; + } + } else if(pushed_key && !key_entries.empty()) { + if(!bag_key_id.empty()) { + key_entries.back().local_key_id = bag_key_id; + } + if(!bag_friendly_name.empty()) { + key_entries.back().friendly_name = bag_friendly_name; + } + } + + bag_seq.verify_end(); + } +} + +/* +* Parse AuthenticatedSafe (sequence of ContentInfo) +*/ +void parse_authenticated_safe(std::span data, + std::string_view password, + std::vector& cert_entries, + std::vector& key_entries, + std::vector& unknown_bag_types, + bool openssl_empty_pwd_compat) { + const OID pkcs7_data_oid = OID::from_string("PKCS7.Data"); + const OID pkcs7_enc_data_oid = OID::from_string("PKCS7.EncryptedData"); + + BER_Decoder auth_safe(data); + BER_Decoder seq = auth_safe.start_sequence(); + + while(seq.more_items()) { + OID content_type; + BER_Decoder content_info = seq.start_sequence(); + content_info.decode(content_type); + + if(content_type == pkcs7_data_oid) { + std::vector safe_contents_data; + BER_Decoder content = content_info.start_context_specific(0); + content.decode(safe_contents_data, ASN1_Type::OctetString); + content.verify_end(); + + BER_Decoder safe_contents(safe_contents_data); + BER_Decoder sc_seq = safe_contents.start_sequence(); + parse_safe_contents(sc_seq, password, cert_entries, key_entries, unknown_bag_types, openssl_empty_pwd_compat); + sc_seq.verify_end(); + safe_contents.verify_end(); + content_info.verify_end(); + } else if(content_type == pkcs7_enc_data_oid) { + BER_Decoder content = content_info.start_context_specific(0); + BER_Decoder enc_data = content.start_sequence(); + + size_t version = 0; + enc_data.decode(version); + + if(version != 0) { + throw Decoding_Error(fmt("PKCS#12: unsupported EncryptedData version: {}", version)); + } + + BER_Decoder enc_content_info = enc_data.start_sequence(); + OID enc_content_type; + AlgorithmIdentifier enc_algo; + enc_content_info.decode(enc_content_type); + enc_content_info.decode(enc_algo); + + if(enc_content_type != pkcs7_data_oid) { + throw Decoding_Error( + fmt("PKCS#12: EncryptedData contentType must be Data, got {}", enc_content_type.to_formatted_string())); + } + + std::vector encrypted_content; + const BER_Object enc_content_obj = enc_content_info.get_next_object(); + + if(enc_content_obj.is_a(0, ASN1_Class::ContextSpecific | ASN1_Class::Constructed)) { + const std::span raw(enc_content_obj.bits(), enc_content_obj.length()); + encrypted_content.reserve(raw.size()); + BER_Decoder chunks(raw); + while(chunks.more_items()) { + std::vector chunk; + chunks.decode(chunk, ASN1_Type::OctetString); + encrypted_content.insert(encrypted_content.end(), chunk.begin(), chunk.end()); + } + chunks.verify_end(); + } else if(enc_content_obj.is_a(0, ASN1_Class::ContextSpecific)) { + encrypted_content.assign(enc_content_obj.bits(), enc_content_obj.bits() + enc_content_obj.length()); + } else { + throw Decoding_Error("PKCS#12: Expected [0] context-specific for encrypted content"); + } + + enc_content_info.verify_end(); + enc_data.verify_end(); + + const secure_vector decrypted = + pkcs12_pbe_decrypt(encrypted_content, password, enc_algo, openssl_empty_pwd_compat); + + BER_Decoder safe_contents(decrypted); + BER_Decoder sc_seq = safe_contents.start_sequence(); + parse_safe_contents(sc_seq, password, cert_entries, key_entries, unknown_bag_types, openssl_empty_pwd_compat); + sc_seq.verify_end(); + safe_contents.verify_end(); + content.verify_end(); + content_info.verify_end(); + } else { + throw Decoding_Error( + fmt("PKCS#12: unsupported AuthenticatedSafe content type {}", content_type.to_formatted_string())); + } + } + + seq.verify_end(); + auth_safe.verify_end(); +} + +} // namespace + +// +// PKCS12_Export_Options +// + +PKCS12_Export_Options::PKCS12_Export_Options(std::string_view password, std::optional friendly_name) : + m_password(password), m_friendly_name(std::move(friendly_name)) {} + +PKCS12_Export_Options PKCS12_Export_Options::modern(std::string_view password, + std::optional friendly_name) { + return PKCS12_Export_Options(password, std::move(friendly_name)); +} + +PKCS12_Export_Options PKCS12_Export_Options::legacy_compat(std::string_view password, + std::optional friendly_name) { + PKCS12_Export_Options opts(password, std::move(friendly_name)); + opts.m_iterations = 2048; + opts.m_key_encryption_algo = "PBE-SHA1-3DES"; + opts.m_mac_digest = "SHA-1"; + return opts; +} + +PKCS12_Export_Options& PKCS12_Export_Options::with_friendly_name(std::string name) { + m_friendly_name = std::move(name); + return *this; +} + +PKCS12_Export_Options& PKCS12_Export_Options::with_iterations(size_t n) { + m_iterations = n; + return *this; +} + +PKCS12_Export_Options& PKCS12_Export_Options::with_key_encryption_algo(std::string algo) { + m_key_encryption_algo = std::move(algo); + return *this; +} + +PKCS12_Export_Options& PKCS12_Export_Options::with_cert_encryption_algo(std::string algo) { + m_cert_encryption_algo = std::move(algo); + return *this; +} + +PKCS12_Export_Options& PKCS12_Export_Options::with_mac_digest(std::string algo) { + m_mac_digest = std::move(algo); + return *this; +} + +PKCS12_Export_Options& PKCS12_Export_Options::without_mac() { + m_include_mac = false; + return *this; +} + +// +// PKCS12 +// + +PKCS12::PKCS12(std::span data, std::string_view password) { + std::vector cert_entries; + std::vector key_entries; + + BER_Decoder pfx(data); + BER_Decoder pfx_seq = pfx.start_sequence(); + + size_t version = 0; + pfx_seq.decode(version); + if(version != 3) { + throw Decoding_Error(fmt("Unsupported PKCS#12 version: {}", version)); + } + + OID auth_safe_type; + std::vector auth_safe_content; + + BER_Decoder auth_safe_info = pfx_seq.start_sequence(); + auth_safe_info.decode(auth_safe_type); + + const OID pkcs7_data_oid = OID::from_string("PKCS7.Data"); + if(auth_safe_type != pkcs7_data_oid) { + throw Decoding_Error("PKCS#12 authSafe must be of type Data"); + } + + BER_Decoder auth_safe_content_wrapper = auth_safe_info.start_context_specific(0); + auth_safe_content_wrapper.decode(auth_safe_content, ASN1_Type::OctetString); + auth_safe_content_wrapper.verify_end(); + auth_safe_info.verify_end(); + + // Tracks whether MAC verification succeeded with OpenSSL's non-conforming + // empty-password encoding; if so, the same convention is used for any + // subsequent EncryptedData / PKCS8ShroudedKeyBag decryption. + bool openssl_empty_pwd_compat = false; + + if(pfx_seq.more_items()) { + BER_Decoder mac_data = pfx_seq.start_sequence(); + + BER_Decoder digest_info = mac_data.start_sequence(); + AlgorithmIdentifier digest_algo; + std::vector mac_value; + digest_info.decode(digest_algo); + digest_info.decode(mac_value, ASN1_Type::OctetString); + digest_info.verify_end(); + + std::vector mac_salt; + size_t iterations = 1; + mac_data.decode(mac_salt, ASN1_Type::OctetString); + if(mac_data.more_items()) { + mac_data.decode(iterations); + } + mac_data.verify_end(); + if(iterations == 0 || iterations > PKCS12_MAX_ITERATIONS) { + throw Decoding_Error(fmt("PKCS#12 MAC has invalid iteration count: {}", iterations)); + } + + const std::string hash_name = resolve_mac_hash(digest_algo.oid()); + // Try RFC 7292 password encoding first. If MAC verification fails and + // the password is empty, retry with OpenSSL's non-conforming empty + // encoding (some OpenSSL releases pass an empty byte string to the KDF + // instead of the RFC {0x00,0x00} form when the password is empty). + // Propagate the chosen convention to any subsequent PBE decryption. + try { + verify_mac(auth_safe_content, mac_value, mac_salt, iterations, hash_name, password, false); + } catch(const Invalid_Authentication_Tag&) { + if(!password.empty()) { + throw; + } + verify_mac(auth_safe_content, mac_value, mac_salt, iterations, hash_name, password, true); + openssl_empty_pwd_compat = true; + } + } + + parse_authenticated_safe( + auth_safe_content, password, cert_entries, key_entries, m_unknown_bag_types, openssl_empty_pwd_compat); + + // Move all parsed keys into storage. + m_private_keys.reserve(key_entries.size()); + for(auto& ke : key_entries) { + m_private_keys.push_back(std::move(ke.key)); + } + + // Capture bundle-level attributes from the first key (if any), or from + // the end-entity certificate (if found below). + if(!key_entries.empty()) { + if(!key_entries.front().friendly_name.empty()) { + m_friendly_name = key_entries.front().friendly_name; + } + if(!key_entries.front().local_key_id.empty()) { + m_local_key_id = key_entries.front().local_key_id; + } + } + + // Reorder certificates so the end-entity (cert matching the first key) + // comes first; rest follow in original order. Match prefers localKeyId, + // falls back to subjectPublicKeyInfo comparison. + std::optional end_entity_idx; + if(!cert_entries.empty() && !m_private_keys.empty()) { + const auto& first_key = m_private_keys.front(); + const auto& first_key_id = key_entries.empty() ? std::vector{} : key_entries.front().local_key_id; + + if(!first_key_id.empty()) { + for(size_t i = 0; i < cert_entries.size(); ++i) { + if(cert_entries[i].local_key_id == first_key_id) { + end_entity_idx = i; + break; + } + } + } + if(!end_entity_idx) { + const auto key_spki = first_key->subject_public_key(); + for(size_t i = 0; i < cert_entries.size(); ++i) { + try { + if(cert_entries[i].cert.subject_public_key_info() == key_spki) { + end_entity_idx = i; + break; + } + } catch(const Decoding_Error&) { + // Certificate with unsupported key algorithm - skip + } + } + } + } + + m_certificates.reserve(cert_entries.size()); + if(end_entity_idx) { + m_certificates.push_back(std::move(cert_entries[*end_entity_idx].cert)); + if(!m_friendly_name && !cert_entries[*end_entity_idx].friendly_name.empty()) { + m_friendly_name = cert_entries[*end_entity_idx].friendly_name; + } + if(!m_local_key_id && !cert_entries[*end_entity_idx].local_key_id.empty()) { + m_local_key_id = cert_entries[*end_entity_idx].local_key_id; + } + for(size_t i = 0; i < cert_entries.size(); ++i) { + if(i != *end_entity_idx) { + // Still surface any friendly name found on non-end-entity certs + // when the bundle doesn't have one yet (some producers attach the + // attribute to the CA bag instead of the end-entity bag). + if(!m_friendly_name && !cert_entries[i].friendly_name.empty()) { + m_friendly_name = cert_entries[i].friendly_name; + } + m_certificates.push_back(std::move(cert_entries[i].cert)); + } + } + } else { + for(auto& ce : cert_entries) { + if(!m_friendly_name && !ce.friendly_name.empty()) { + m_friendly_name = ce.friendly_name; + } + m_certificates.push_back(std::move(ce.cert)); + } + } + + pfx_seq.verify_end(); + pfx_seq.end_cons(); + pfx.verify_end("PKCS#12: trailing data after PFX"); +} + +std::vector PKCS12::ca_certificates() const { + if(m_certificates.size() < 2) { + return {}; + } + const auto ee = end_entity_certificate(); + std::vector result; + result.reserve(m_certificates.size() - 1); + if(ee) { + // Skip the first certificate matching the end-entity (only one, in case + // the bundle contains multiple certs signed for the same key, e.g. an + // old leaf still kept alongside a renewed one). + const auto ee_spki = ee->subject_public_key_info(); + bool skipped = false; + for(const auto& c : m_certificates) { + if(!skipped && c.subject_public_key_info() == ee_spki) { + skipped = true; + continue; + } + result.push_back(c); + } + } else { + // No end-entity (e.g. key-less bundle): treat the first stored cert as + // the "primary" and surface the rest as CA / chain certs. This matches + // the storage order used by parsing. + for(size_t i = 1; i < m_certificates.size(); ++i) { + result.push_back(m_certificates[i]); + } + } + return result; +} + +std::optional PKCS12::end_entity_certificate() const { + if(m_certificates.empty() || m_private_keys.empty()) { + return std::nullopt; + } + const auto& first_key = m_private_keys.front(); + const auto key_spki = first_key->subject_public_key(); + for(const auto& c : m_certificates) { + try { + if(c.subject_public_key_info() == key_spki) { + return c; + } + } catch(const Decoding_Error&) { + // Skip certificates with unsupported algorithms + } + } + return std::nullopt; +} + +void PKCS12::add_key(std::shared_ptr key) { + if(!key) { + throw Invalid_Argument("PKCS12::add_key: key must not be null"); + } + m_private_keys.push_back(std::move(key)); +} + +void PKCS12::add_certificate(X509_Certificate cert) { + m_certificates.push_back(std::move(cert)); +} + +void PKCS12::set_friendly_name(std::string name) { + m_friendly_name = std::move(name); +} + +void PKCS12::clear_friendly_name() { + m_friendly_name.reset(); +} + +void PKCS12::set_local_key_id(std::vector id) { + m_local_key_id = std::move(id); +} + +void PKCS12::clear_local_key_id() { + m_local_key_id.reset(); +} + +std::vector PKCS12::export_to(const PKCS12_Export_Options& options, RandomNumberGenerator& rng) const { + if(m_private_keys.empty() && m_certificates.empty()) { + throw Invalid_Argument("PKCS#12::export_to requires at least a key or certificate"); + } + + validate_options(options); + + // Determine end-entity certificate(s). With a single key we pair it + // against a cert matching its SPKI; that pair gets the + // friendly_name/localKeyId from options or the bundle. + std::optional end_entity_idx; + if(!m_private_keys.empty() && !m_certificates.empty()) { + const auto& first_key = m_private_keys.front(); + const auto key_spki = first_key->subject_public_key(); + for(size_t i = 0; i < m_certificates.size(); ++i) { + try { + if(m_certificates[i].subject_public_key_info() == key_spki) { + end_entity_idx = i; + break; + } + } catch(const Decoding_Error&) { + // skip + } + } + if(!end_entity_idx) { + throw Invalid_Argument("PKCS#12::export_to: private key does not match any certificate"); + } + } + + const OID cert_bag_oid = OID::from_string("PKCS12.CertBag"); + const OID shrouded_key_oid = OID::from_string("PKCS12.PKCS8ShroudedKeyBag"); + const OID x509_cert_oid = OID::from_string("PKCS9.X509Certificate"); + const OID friendly_name_oid = OID::from_string("PKCS9.FriendlyName"); + const OID local_key_id_oid = OID::from_string("PKCS9.LocalKeyId"); + const OID pkcs7_data_oid = OID::from_string("PKCS7.Data"); + const OID pkcs7_enc_data_oid = OID::from_string("PKCS7.EncryptedData"); + + // Pick the friendly-name and local-key-id used by attribute encoding. + // Options take precedence over the bundle-level fields. + const std::optional& friendly_name = + options.friendly_name().has_value() ? options.friendly_name() : m_friendly_name; + + std::vector local_key_id; + if(m_local_key_id) { + local_key_id = *m_local_key_id; + } else if(end_entity_idx) { + local_key_id = m_certificates[*end_entity_idx].subject_public_key_bitstring_sha1(); + } else if(!m_private_keys.empty()) { + // Key-only bundle: derive from SHA-1 of the public key bits (matching the + // convention used by X509_Certificate::subject_public_key_bitstring_sha1). + auto sha1 = HashFunction::create_or_throw("SHA-1"); + const auto pub_bits = m_private_keys.front()->public_key_bits(); + sha1->update(pub_bits); + local_key_id = unlock(sha1->final()); + } + + auto write_attributes = [&](DER_Encoder& enc) { + const bool has_fn = friendly_name.has_value() && !friendly_name->empty(); + const bool has_id = !local_key_id.empty(); + if(!has_fn && !has_id) { + return; + } + enc.start_set(); + if(has_fn) { + enc.start_sequence(); + enc.encode(friendly_name_oid); + enc.start_set(); + encode_bmpstring(enc, *friendly_name); + enc.end_cons(); + enc.end_cons(); + } + if(has_id) { + enc.start_sequence(); + enc.encode(local_key_id_oid); + enc.start_set(); + enc.encode(local_key_id, ASN1_Type::OctetString); + enc.end_cons(); + enc.end_cons(); + } + enc.end_cons(); + }; + + // CertBags + std::vector cert_safe_contents; + if(!m_certificates.empty()) { + DER_Encoder cert_bags(cert_safe_contents); + cert_bags.start_sequence(); + + auto add_cert_bag = [&](const X509_Certificate& c, bool add_attrs) { + cert_bags.start_sequence(); + cert_bags.encode(cert_bag_oid); + + cert_bags.start_context_specific(0); + cert_bags.start_sequence(); + cert_bags.encode(x509_cert_oid); + cert_bags.start_context_specific(0); + cert_bags.encode(c.BER_encode(), ASN1_Type::OctetString); + cert_bags.end_cons(); + cert_bags.end_cons(); + cert_bags.end_cons(); + + if(add_attrs) { + write_attributes(cert_bags); + } + + cert_bags.end_cons(); + }; + + // End-entity first (so the file is read in the typical order), then + // the rest in their stored order. + if(end_entity_idx) { + add_cert_bag(m_certificates[*end_entity_idx], true); + for(size_t i = 0; i < m_certificates.size(); ++i) { + if(i != *end_entity_idx) { + add_cert_bag(m_certificates[i], false); + } + } + } else { + for(const auto& c : m_certificates) { + add_cert_bag(c, false); + } + } + + cert_bags.end_cons(); + } + + // Key SafeBag(s) + std::vector key_safe_contents; + if(!m_private_keys.empty()) { + DER_Encoder key_bags(key_safe_contents); + key_bags.start_sequence(); + + for(size_t i = 0; i < m_private_keys.size(); ++i) { + const Private_Key& key = *m_private_keys[i]; + + key_bags.start_sequence(); + key_bags.encode(shrouded_key_oid); + + secure_vector pkcs8_key = PKCS8::BER_encode(key); + auto [enc_algo, enc_key] = + pkcs12_pbe_encrypt(pkcs8_key, options.password(), options.key_encryption_algo(), options.iterations(), rng); + + key_bags.start_context_specific(0); + key_bags.start_sequence(); + key_bags.encode(enc_algo); + key_bags.encode(enc_key, ASN1_Type::OctetString); + key_bags.end_cons(); + key_bags.end_cons(); + + // Only the first key carries the bundle-level attributes (preserves + // the historical single-key behavior). + if(i == 0) { + write_attributes(key_bags); + } + + key_bags.end_cons(); + } + key_bags.end_cons(); + } + + // AuthenticatedSafe + std::vector auth_safe_content; + DER_Encoder auth_safe(auth_safe_content); + auth_safe.start_sequence(); + + if(!cert_safe_contents.empty()) { + if(!options.cert_encryption_algo().empty()) { + auto [enc_algo, enc_data] = pkcs12_pbe_encrypt( + cert_safe_contents, options.password(), options.cert_encryption_algo(), options.iterations(), rng); + + auth_safe.start_sequence(); + auth_safe.encode(pkcs7_enc_data_oid); + auth_safe.start_context_specific(0); + auth_safe.start_sequence(); + auth_safe.encode(size_t(0)); + auth_safe.start_sequence(); + auth_safe.encode(pkcs7_data_oid); + auth_safe.encode(enc_algo); + auth_safe.add_object(ASN1_Type(0), ASN1_Class::ContextSpecific, enc_data); + auth_safe.end_cons(); + auth_safe.end_cons(); + auth_safe.end_cons(); + auth_safe.end_cons(); + } else { + auth_safe.start_sequence(); + auth_safe.encode(pkcs7_data_oid); + auth_safe.start_context_specific(0); + auth_safe.encode(cert_safe_contents, ASN1_Type::OctetString); + auth_safe.end_cons(); + auth_safe.end_cons(); + } + } + + if(!key_safe_contents.empty()) { + auth_safe.start_sequence(); + auth_safe.encode(pkcs7_data_oid); + auth_safe.start_context_specific(0); + auth_safe.encode(key_safe_contents, ASN1_Type::OctetString); + auth_safe.end_cons(); + auth_safe.end_cons(); + } + + auth_safe.end_cons(); + + // PFX + std::vector pfx_data; + DER_Encoder pfx(pfx_data); + pfx.start_sequence(); + pfx.encode(size_t(3)); + + pfx.start_sequence(); + pfx.encode(pkcs7_data_oid); + pfx.start_context_specific(0); + pfx.encode(auth_safe_content, ASN1_Type::OctetString); + pfx.end_cons(); + pfx.end_cons(); + + if(options.include_mac()) { + const std::string& mac_hash = options.mac_digest(); + + auto hmac = MessageAuthenticationCode::create_or_throw(fmt("HMAC({})", mac_hash)); + + std::vector mac_salt(hmac->output_length()); + rng.randomize(mac_salt.data(), mac_salt.size()); + const size_t mac_key_len = hmac->output_length(); + secure_vector mac_key(mac_key_len); + const PKCS12_KDF kdf(HashFunction::create_or_throw(mac_hash), 3, options.iterations()); + kdf.derive_key(mac_key.data(), + mac_key_len, + options.password().data(), + options.password().size(), + mac_salt.data(), + mac_salt.size()); + + hmac->set_key(mac_key); + hmac->update(auth_safe_content); + const secure_vector mac_value = hmac->final(); + + pfx.start_sequence(); + pfx.start_sequence(); + const auto param_encoding = + (mac_hash == "SHA-1") ? AlgorithmIdentifier::USE_NULL_PARAM : AlgorithmIdentifier::USE_EMPTY_PARAM; + pfx.encode(AlgorithmIdentifier(OID::from_string(mac_hash), param_encoding)); + pfx.encode(mac_value, ASN1_Type::OctetString); + pfx.end_cons(); + pfx.encode(mac_salt, ASN1_Type::OctetString); + if(options.iterations() != 1) { + pfx.encode(options.iterations()); + } + pfx.end_cons(); + } + + pfx.end_cons(); + + return pfx_data; +} + +} // namespace Botan diff --git a/src/lib/pkcs12/pkcs12.h b/src/lib/pkcs12/pkcs12.h new file mode 100644 index 00000000000..7049f950e6a --- /dev/null +++ b/src/lib/pkcs12/pkcs12.h @@ -0,0 +1,254 @@ +/* +* PKCS#12 +* (C) 2026 Damiano Mazzella +* +* Botan is released under the Simplified BSD License (see license.txt) +*/ + +#ifndef BOTAN_PKCS12_H_ +#define BOTAN_PKCS12_H_ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Botan { + +class RandomNumberGenerator; + +/// Maximum allowed SafeContentsBag nesting depth during parsing (anti-DoS). +inline constexpr size_t PKCS12_MAX_NESTING = 10; + +/** +* Options controlling PKCS#12/PFX export. +* +* Use one of the static pseudo-constructors for the common cases: +* +* - @ref modern - PBES2-SHA256-AES256, SHA-256 MAC, 100 000 iterations +* - @ref legacy_compat - PBE-SHA1-3DES, SHA-1 MAC, 2 048 iterations +* +* For custom configurations construct directly and use the @c with_*() +* mutators (chainable). Any field not set explicitly defaults to the +* "modern" value. +*/ +class BOTAN_PUBLIC_API(3, 13) PKCS12_Export_Options final { + public: + /** + * @param password password protecting the file. Empty is allowed + * (PKCS#12 defines an encoding for it), but must not + * be empty when @ref include_mac is true. + * @param friendly_name optional friendly name attribute stored on the + * private key bag and on the matching end-entity + * certificate bag. + */ + explicit PKCS12_Export_Options(std::string_view password, std::optional friendly_name = {}); + + /** + * Modern defaults: PBES2-SHA256-AES256, SHA-256 MAC, 100 000 iterations. + */ + static PKCS12_Export_Options modern(std::string_view password, std::optional friendly_name = {}); + + /** + * Legacy-compatible defaults: PBE-SHA1-3DES, SHA-1 MAC, 2 048 iterations. + * Use when interoperability with old software (Java keytool pre-2019, + * older OpenSSL releases, Windows pre-Windows-10) is required. + */ + static PKCS12_Export_Options legacy_compat(std::string_view password, + std::optional friendly_name = {}); + + /// Override the friendly-name attribute (otherwise taken from the bundle). + PKCS12_Export_Options& with_friendly_name(std::string name); + + /// Set number of KDF iterations. + PKCS12_Export_Options& with_iterations(size_t n); + + /// Set the private key encryption algorithm (PKCS#12 PBE or PBES2 name). + PKCS12_Export_Options& with_key_encryption_algo(std::string algo); + + /** + * Set the certificate encryption algorithm. Empty string (the default) + * means certificates are stored unencrypted (inside an unencrypted + * SafeContents); pass a non-empty algorithm to wrap them. + */ + PKCS12_Export_Options& with_cert_encryption_algo(std::string algo); + + /// Set the digest used for the integrity MAC. + PKCS12_Export_Options& with_mac_digest(std::string algo); + + /// Disable the integrity MAC. Generally not recommended. + PKCS12_Export_Options& without_mac(); + + const std::string& password() const { return m_password; } + + const std::optional& friendly_name() const { return m_friendly_name; } + + size_t iterations() const { return m_iterations; } + + const std::string& key_encryption_algo() const { return m_key_encryption_algo; } + + /// Empty means: store certificates unencrypted. + const std::string& cert_encryption_algo() const { return m_cert_encryption_algo; } + + const std::string& mac_digest() const { return m_mac_digest; } + + bool include_mac() const { return m_include_mac; } + + private: + std::string m_password; + std::optional m_friendly_name; + size_t m_iterations = 100000; + std::string m_key_encryption_algo = "PBES2-SHA256-AES256"; + std::string m_cert_encryption_algo; + std::string m_mac_digest = "SHA-256"; + bool m_include_mac = true; +}; + +/** +* PKCS#12/PFX bundle: parsed contents, mutable container, and exporter. +* +* PKCS#12 is a file format for storing cryptographic objects (private keys +* and X.509 certificates) together, typically protected by a password. +* +* The class can be used both to inspect an existing PFX and to build a new +* one. Construction from bytes parses an existing file; the default +* constructor produces an empty bundle that the caller populates with +* mutators (@ref add_key, @ref add_certificate, ...) before calling +* @ref export_to to serialize. +* +* @code +* // Parse +* Botan::PKCS12 p12(pfx_bytes, "password"); +* if(!p12.private_keys().empty()) { +* const auto& key = p12.private_keys().front(); +* // ... +* } +* if(auto ee = p12.end_entity_certificate()) { +* // ... +* } +* +* // Build +* Botan::PKCS12 out; +* out.set_friendly_name("My Bundle"); +* out.add_key(my_key); +* out.add_certificate(my_cert); +* for(const auto& ca : ca_chain) { +* out.add_certificate(ca); +* } +* const auto blob = out.export_to( +* Botan::PKCS12_Export_Options::modern("password"), rng); +* @endcode +*/ +class BOTAN_PUBLIC_API(3, 13) PKCS12 final { + public: + /// Construct an empty bundle. + PKCS12() = default; + + /** + * Parse a PKCS#12/PFX file. + * + * @param data the PFX file contents + * @param password the password to decrypt the file + * @throws Decoding_Error if parsing fails + * @throws Invalid_Authentication_Tag if MAC verification fails + */ + PKCS12(std::span data, std::string_view password); + + /** + * Private keys stored in the bundle, in the order they appear in the + * PFX (for a parsed file) or in insertion order (for a built one). + * PKCS#12 allows multiple keys per file; parsing currently surfaces all + * KeyBag / PKCS8ShroudedKeyBag entries. + */ + const std::vector>& private_keys() const { return m_private_keys; } + + /** + * Certificates stored in the bundle, in the order they appear in the + * PFX or in insertion order. The end-entity certificate (if any) is + * not separated from CA/intermediate certificates at storage level; + * use @ref end_entity_certificate to obtain it. + */ + const std::vector& certificates() const { return m_certificates; } + + /** + * @return the first certificate whose subjectPublicKeyInfo matches one + * of the stored private keys, or @c nullopt if none match + * (e.g. a certificate-only or key-only bundle). + */ + std::optional end_entity_certificate() const; + + /** + * Convenience helper: every certificate except the one returned by + * @ref end_entity_certificate. Returned in storage order. + */ + std::vector ca_certificates() const; + + /** + * Friendly-name attribute attached to the private key / end-entity + * certificate bag, if present. + */ + const std::optional& friendly_name() const { return m_friendly_name; } + + /** + * localKeyId attribute attached to the private key / end-entity + * certificate bag, if present. + */ + const std::optional>& local_key_id() const { return m_local_key_id; } + + /** + * OIDs of bag types encountered during parsing but not handled by this + * implementation (e.g. SecretBag). Empty for normal files and for + * bundles constructed in-memory. + */ + const std::vector& unknown_bag_types() const { return m_unknown_bag_types; } + + /// Add a private key. PKCS#12 supports multiple keys per file. + void add_key(std::shared_ptr key); + + /// Add a certificate. End-entity vs CA is determined at export time + /// by matching against stored keys. + void add_certificate(X509_Certificate cert); + + /// Set (or replace) the friendly-name attribute. + void set_friendly_name(std::string name); + + /// Clear the friendly-name attribute. + void clear_friendly_name(); + + /// Set (or replace) the localKeyId attribute. + void set_local_key_id(std::vector id); + + /// Clear the localKeyId attribute. + void clear_local_key_id(); + + /** + * Serialize the bundle as a PKCS#12/PFX file. + * + * @param options export options (password, algorithms, ...). + * @param rng RNG used to generate salts, IVs and (if requested) the + * localKeyId when none is set explicitly. + * @throws Invalid_Argument if @p options is internally inconsistent + * (e.g. unsupported algorithm or empty password with MAC). + * @throws Invalid_Argument if a stored private key does not match any + * stored certificate (this implementation requires the + * end-entity cert to be present when a key is exported). + */ + std::vector export_to(const PKCS12_Export_Options& options, RandomNumberGenerator& rng) const; + + private: + std::vector> m_private_keys; + std::vector m_certificates; + std::optional m_friendly_name; + std::optional> m_local_key_id; + std::vector m_unknown_bag_types; +}; + +} // namespace Botan + +#endif diff --git a/src/lib/pkcs12/pkcs12_pbe/info.txt b/src/lib/pkcs12/pkcs12_pbe/info.txt new file mode 100644 index 00000000000..32bba22555e --- /dev/null +++ b/src/lib/pkcs12/pkcs12_pbe/info.txt @@ -0,0 +1,23 @@ + +PKCS12_PBE -> 20260305 + + + +name -> "PKCS#12 PBE" +brief -> "PKCS#12 password-based encryption (RFC 7292 Appendix B)" + + + +asn1 +cbc +des +sha1 +sha2_32 +aes +pkcs12_kdf +pbes2 + + + +pkcs12_pbe.h + diff --git a/src/lib/pkcs12/pkcs12_pbe/pkcs12_pbe.cpp b/src/lib/pkcs12/pkcs12_pbe/pkcs12_pbe.cpp new file mode 100644 index 00000000000..1d87a0938c3 --- /dev/null +++ b/src/lib/pkcs12/pkcs12_pbe/pkcs12_pbe.cpp @@ -0,0 +1,168 @@ +/* +* PKCS#12 PBE (RFC 7292 Appendix B) +* (C) 2026 Damiano Mazzella +* +* Botan is released under the Simplified BSD License (see license.txt) +*/ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace Botan { + +namespace { + +// Maps a PKCS#12 PBE OID/name to its cipher parameters +struct PKCS12_PBE_Params { + OID oid; + std::string cipher_name; + size_t key_len; +}; + +PKCS12_PBE_Params pkcs12_pbe_params_for_oid(const OID& oid) { + if(oid == OID::from_string("PBE-SHA1-3DES")) { + return {oid, "TripleDES/CBC", 24}; + } + if(oid == OID::from_string("PBE-SHA1-2DES")) { + return {oid, "TripleDES/CBC", 16}; + } + throw Decoding_Error(fmt("Unsupported PKCS#12 PBE algorithm: {}", oid.to_string())); +} + +PKCS12_PBE_Params pkcs12_pbe_params_for_algo(std::string_view algo) { + if(algo == "PBE-SHA1-3DES") { + return {OID::from_string("PBE-SHA1-3DES"), "TripleDES/CBC", 24}; + } + if(algo == "PBE-SHA1-2DES") { + return {OID::from_string("PBE-SHA1-2DES"), "TripleDES/CBC", 16}; + } + throw Invalid_Argument(fmt("Unsupported PKCS#12 PBE algorithm: {}", algo)); +} + +// Derive key and IV via PKCS#12 KDF; expands 2-key 3DES (key_len==16) to 24 bytes. +// When @p openssl_empty_pwd_compat is true and the password is empty, the KDF +// is fed an empty byte string (matching OpenSSL's non-conforming behavior); +// otherwise the RFC 7292 encoding (two-byte {0x00,0x00} terminator for any +// UTF-8 password, empty encoded as {0x00,0x00}) is used. +std::pair, secure_vector> pkcs12_derive_key_iv(std::string_view password, + const std::vector& salt, + size_t iterations, + const PKCS12_PBE_Params& params, + bool openssl_empty_pwd_compat) { + constexpr size_t iv_len = 8; // DES/3DES block size + secure_vector key(params.key_len); + secure_vector iv(iv_len); + + const auto hash = HashFunction::create_or_throw("SHA-1"); + const bool ossl_empty = openssl_empty_pwd_compat && password.empty(); + + if(ossl_empty) { + // Feed an empty password byte string to the KDF (OpenSSL compat). + pkcs12_kdf({key.data(), params.key_len}, {}, {salt.data(), salt.size()}, iterations, 1, *hash); + pkcs12_kdf({iv.data(), iv_len}, {}, {salt.data(), salt.size()}, iterations, 2, *hash); + } else { + const PKCS12_KDF kdf_key(hash->new_object(), 1, iterations); + kdf_key.derive_key(key.data(), params.key_len, password.data(), password.size(), salt.data(), salt.size()); + + const PKCS12_KDF kdf_iv(hash->new_object(), 2, iterations); + kdf_iv.derive_key(iv.data(), iv_len, password.data(), password.size(), salt.data(), salt.size()); + } + + if(params.key_len == 16) { // 2DES: expand to 24 bytes by repeating the first key + key.resize(24); + std::copy(key.begin(), key.begin() + 8, key.begin() + 16); + } + return {std::move(key), std::move(iv)}; +} + +} // namespace + +secure_vector pkcs12_pbe_decrypt(std::span ciphertext, + std::string_view password, + const AlgorithmIdentifier& pbe_algo, + bool openssl_empty_pwd_compat) { + const OID& oid = pbe_algo.oid(); + + if(oid == OID::from_string("PBE-PKCS5v20")) { + // PBES2 (RFC 8018) uses PBKDF2, which is unambiguous for empty + // passwords, so the OpenSSL empty-password quirk does not apply. + return pbes2_decrypt(ciphertext, password, pbe_algo.parameters()); + } + + std::vector salt; + size_t iterations = 0; + BER_Decoder(pbe_algo.parameters()) + .start_sequence() + .decode(salt, ASN1_Type::OctetString) + .decode(iterations) + .verify_end() + .end_cons(); + + if(iterations == 0 || iterations > PKCS12_MAX_ITERATIONS) { + throw Decoding_Error(fmt("PKCS#12 PBE has invalid iteration count: {}", iterations)); + } + + const auto params = pkcs12_pbe_params_for_oid(oid); + auto [key, iv] = pkcs12_derive_key_iv(password, salt, iterations, params, openssl_empty_pwd_compat); + + auto cipher = Cipher_Mode::create_or_throw(params.cipher_name, Cipher_Dir::Decryption); + cipher->set_key(key); + cipher->start(iv); + + secure_vector plaintext(ciphertext.begin(), ciphertext.end()); + cipher->finish(plaintext); + + return plaintext; +} + +std::pair> pkcs12_pbe_encrypt(std::span plaintext, + std::string_view password, + std::string_view algo, + size_t iterations, + RandomNumberGenerator& rng) { + if(iterations == 0 || iterations > PKCS12_MAX_ITERATIONS) { + throw Invalid_Argument(fmt("PKCS#12 PBE: iteration count must be between 1 and {}", PKCS12_MAX_ITERATIONS)); + } + + if(algo == "PBES2-SHA256-AES256") { + auto [aid, ct] = pbes2_encrypt_iter(plaintext, password, iterations, "AES-256/CBC", "SHA-256", rng); + return {std::move(aid), std::move(ct)}; + } + if(algo == "PBES2-SHA256-AES128") { + auto [aid, ct] = pbes2_encrypt_iter(plaintext, password, iterations, "AES-128/CBC", "SHA-256", rng); + return {std::move(aid), std::move(ct)}; + } + + const auto params = pkcs12_pbe_params_for_algo(algo); + + std::vector salt(8); + rng.randomize(salt.data(), salt.size()); + + auto [key, iv] = pkcs12_derive_key_iv(password, salt, iterations, params, false); + + auto cipher = Cipher_Mode::create_or_throw(params.cipher_name, Cipher_Dir::Encryption); + cipher->set_key(key); + cipher->start(iv); + + std::vector ciphertext(plaintext.begin(), plaintext.end()); + cipher->finish(ciphertext); + + std::vector enc_params; + DER_Encoder(enc_params).start_sequence().encode(salt, ASN1_Type::OctetString).encode(iterations).end_cons(); + + return {AlgorithmIdentifier(params.oid, enc_params), std::move(ciphertext)}; +} + +} // namespace Botan diff --git a/src/lib/pkcs12/pkcs12_pbe/pkcs12_pbe.h b/src/lib/pkcs12/pkcs12_pbe/pkcs12_pbe.h new file mode 100644 index 00000000000..943f118f01f --- /dev/null +++ b/src/lib/pkcs12/pkcs12_pbe/pkcs12_pbe.h @@ -0,0 +1,60 @@ +/* +* PKCS#12 PBE (RFC 7292 Appendix B) +* (C) 2026 Damiano Mazzella +* +* Botan is released under the Simplified BSD License (see license.txt) +*/ + +#ifndef BOTAN_PKCS12_PBE_H_ +#define BOTAN_PKCS12_PBE_H_ + +#include +#include +#include +#include +#include +#include + +namespace Botan { + +class RandomNumberGenerator; + +/// Maximum allowed iteration count for PKCS#12 KDF/PBE/MAC operations +inline constexpr size_t PKCS12_MAX_ITERATIONS = 1'000'000; + +/** +* Decrypt data protected by PKCS#12 PBE (RFC 7292 Appendix B) or PBES2. +* @param ciphertext the encrypted data +* @param password the decryption password +* @param pbe_algo the AlgorithmIdentifier from the EncryptedData structure +* @param openssl_empty_pwd_compat if @c true and @p password is empty, the +* password is fed to the PKCS#12 KDF as an empty byte string (matching +* OpenSSL's non-conforming behavior) instead of the RFC 7292 form +* (a two-byte {0x00,0x00} terminator). Has no effect on PBES2 or when +* the password is non-empty. +*/ +secure_vector pkcs12_pbe_decrypt(std::span ciphertext, + std::string_view password, + const AlgorithmIdentifier& pbe_algo, + bool openssl_empty_pwd_compat = false); + +/** +* Encrypt data using PKCS#12 PBE (RFC 7292 Appendix B) or PBES2. +* @param plaintext the data to encrypt +* @param password the encryption password +* @param algo algorithm name: "PBES2-SHA256-AES256", "PBES2-SHA256-AES128", +* "PBE-SHA1-3DES" (legacy), "PBE-SHA1-2DES" (legacy). +* Higher-level APIs default to "PBES2-SHA256-AES256" via PKCS12_Options. +* @param iterations PBKDF iteration count +* @param rng a random number generator +* @return the AlgorithmIdentifier and encrypted data +*/ +std::pair> pkcs12_pbe_encrypt(std::span plaintext, + std::string_view password, + std::string_view algo, + size_t iterations, + RandomNumberGenerator& rng); + +} // namespace Botan + +#endif diff --git a/src/scripts/test_cli.py b/src/scripts/test_cli.py index 8a4e3647423..2d003b5acf6 100755 --- a/src/scripts/test_cli.py +++ b/src/scripts/test_cli.py @@ -1600,6 +1600,143 @@ def cli_pk_encrypt_tests(tmp_dir): test_cli("pk_decrypt", [rsa_priv_key, ctext_file, "--output=%s" % (recovered_file)], "") test_cli("hash", ["--no-fsname", "--algo=SHA-256", recovered_file], rng_output_hash) +def cli_pkcs12_tests(tmp_dir): + if not check_for_command("pkcs12_export") or not check_for_command("pkcs12_info"): + logging.info("Skipping PKCS#12 CLI tests: pkcs12_export/pkcs12_info not available") + return + + priv_key = os.path.join(tmp_dir, 'leaf.key') + cert_file = os.path.join(tmp_dir, 'leaf.crt') + pfx_file = os.path.join(tmp_dir, 'leaf.pfx') + out_key = os.path.join(tmp_dir, 'out.key') + out_cert = os.path.join(tmp_dir, 'out.crt') + + test_cli("keygen", ["--algo=ECDSA", "--params=secp256r1", "--output=" + priv_key], "") + test_cli("gen_self_signed", [priv_key, "PKCS12Test", "--output=" + cert_file], "") + + # Basic export (default: PBES2-SHA256-AES256, SHA-256 MAC) + test_cli("pkcs12_export", + ["--pass=hunter2", "--output=" + pfx_file, priv_key, cert_file], "") + + # pkcs12_info info-only (no output file args) + # Note: logging.error() is a hard failure in this harness (TestLogHandler increments TESTS_FAILED) + import_info = test_cli("pkcs12_info", ["--pass=hunter2", pfx_file], None) + if "ECDSA" not in import_info: + logging.error("pkcs12_info info missing key algorithm: %s", import_info) + if 'PKCS12Test' not in import_info: + logging.error("pkcs12_info info missing subject: %s", import_info) + # SHA-256 fingerprint should be in info output + if "SHA-256 Fingerprint" not in import_info: + logging.error("pkcs12_info info missing SHA-256 fingerprint: %s", import_info) + + # pkcs12_info: extract key and cert to files + test_cli("pkcs12_info", + ["--pass=hunter2", "--key-out=" + out_key, "--cert-out=" + out_cert, pfx_file], + None) + + if not os.path.exists(out_key): + logging.error("pkcs12_info did not write key file") + if not os.path.exists(out_cert): + logging.error("pkcs12_info did not write cert file") + + # Roundtrip: cert extracted from PFX must match original (DER fingerprint comparison) + fp_orig = test_cli("cert_info", ["--fingerprint", cert_file], None) + fp_extr = test_cli("cert_info", ["--fingerprint", out_cert], None) + fp_prefix = "Fingerprint: " + orig_fp = next((line for line in fp_orig.splitlines() if line.startswith(fp_prefix)), None) + extr_fp = next((line for line in fp_extr.splitlines() if line.startswith(fp_prefix)), None) + if orig_fp is None or extr_fp is None: + logging.error("pkcs12 roundtrip: cert_info did not produce fingerprint") + elif orig_fp != extr_fp: + logging.error("pkcs12 roundtrip: certificate mismatch (orig=%s extr=%s)", + orig_fp, extr_fp) + + # Roundtrip: public key derived from extracted key must match original + pub_orig = os.path.join(tmp_dir, 'orig.pub') + pub_extr = os.path.join(tmp_dir, 'extr.pub') + test_cli("pkcs8", ["--pub-out", "--output=" + pub_orig, priv_key], "") + test_cli("pkcs8", ["--pub-out", "--output=" + pub_extr, out_key], "") + fp_orig = test_cli("fingerprint", ["--no-fsname", pub_orig], None) + fp_extr = test_cli("fingerprint", ["--no-fsname", pub_extr], None) + if fp_orig != fp_extr: + logging.error("pkcs12 roundtrip: key mismatch after import (orig=%s extr=%s)", fp_orig, fp_extr) + + # Export with explicit legacy cipher for compatibility testing + pfx_legacy = os.path.join(tmp_dir, 'legacy.pfx') + test_cli("pkcs12_export", + ["--pass=hunter2", "--key-cipher=PBE-SHA1-3DES", "--mac-digest=SHA-1", + "--iterations=2048", "--output=" + pfx_legacy, priv_key, cert_file], "") + info_legacy = test_cli("pkcs12_info", ["--pass=hunter2", pfx_legacy], None) + if "ECDSA" not in info_legacy: + logging.error("pkcs12_info (legacy cipher) missing key algorithm: %s", info_legacy) + + # Export with friendly name + pfx_named = os.path.join(tmp_dir, 'named.pfx') + test_cli("pkcs12_export", + ["--pass=hunter2", "--friendly-name=MioTest", + "--output=" + pfx_named, priv_key, cert_file], "") + info_named = test_cli("pkcs12_info", ["--pass=hunter2", pfx_named], None) + if "MioTest" not in info_named: + logging.error("pkcs12 friendly-name not preserved in info output: %s", info_named) + + # Export with MAC SHA-256 explicitly + pfx_sha256mac = os.path.join(tmp_dir, 'sha256mac.pfx') + test_cli("pkcs12_export", + ["--pass=hunter2", "--mac-digest=SHA-256", + "--key-cipher=PBE-SHA1-3DES", "--iterations=2048", + "--output=" + pfx_sha256mac, priv_key, cert_file], "") + test_cli("pkcs12_info", ["--pass=hunter2", pfx_sha256mac], None) + + # Export with cert cipher + pfx_certenc = os.path.join(tmp_dir, 'certenc.pfx') + test_cli("pkcs12_export", + ["--pass=hunter2", "--cert-cipher=PBE-SHA1-3DES", + "--key-cipher=PBE-SHA1-3DES", "--iterations=2048", + "--output=" + pfx_certenc, priv_key, cert_file], "") + test_cli("pkcs12_info", ["--pass=hunter2", pfx_certenc], None) + + # Export with CA chain and test --chain-out + ca_key_file = os.path.join(tmp_dir, 'ca.key') + ca_cert_file = os.path.join(tmp_dir, 'ca.crt') + pfx_chain = os.path.join(tmp_dir, 'chain.pfx') + chain_out = os.path.join(tmp_dir, 'chain.pem') + + test_cli("keygen", ["--algo=ECDSA", "--params=secp256r1", "--output=" + ca_key_file], "") + test_cli("gen_self_signed", [ca_key_file, "TestCA", "--output=" + ca_cert_file], "") + + test_cli("pkcs12_export", + ["--pass=hunter2", "--key-cipher=PBE-SHA1-3DES", "--iterations=2048", + "--output=" + pfx_chain, priv_key, cert_file, ca_cert_file], "") + test_cli("pkcs12_info", + ["--pass=hunter2", "--chain-out=" + chain_out, pfx_chain], None) + if not os.path.exists(chain_out): + logging.error("pkcs12_info --chain-out did not produce a file") + + # Export key with output password protection + out_key_enc = os.path.join(tmp_dir, 'out_enc.key') + test_cli("pkcs12_info", + ["--pass=hunter2", + "--key-out=" + out_key_enc, + "--out-key-pass=keypassword", + "--key-pbkdf-iter=2048", + pfx_file], None) + if not os.path.exists(out_key_enc): + logging.error("pkcs12_info --out-key-pass: key file not written") + else: + with open(out_key_enc, encoding='utf-8') as f: + key_pem = f.read() + if "ENCRYPTED" not in key_pem: + logging.error("pkcs12_info --out-key-pass: output key is not encrypted") + + # Export with empty password and no MAC (key is still encrypted via PBE) + pfx_noenc = os.path.join(tmp_dir, 'noenc.pfx') + test_cli("pkcs12_export", + ["--pass=", "--no-mac", "--key-cipher=PBE-SHA1-3DES", + "--output=" + pfx_noenc, priv_key, cert_file], "") + info_noenc = test_cli("pkcs12_info", ["--pass=", pfx_noenc], None) + if "ECDSA" not in info_noenc: + logging.error("pkcs12_info (no-mac) missing key algorithm: %s", info_noenc) + def cli_uuid_tests(_tmp_dir): test_cli("uuid", [], "D80F88F6-ADBE-45AC-B10C-3602E67D985B") @@ -1899,6 +2036,7 @@ def main(args=None): cli_pbkdf_tune_tests, cli_pk_encrypt_tests, cli_pk_workfactor_tests, + cli_pkcs12_tests, cli_psk_db_tests, cli_rng_tests, cli_roughtime_check_tests, diff --git a/src/tests/data/pkcs12/cert-aes256cbc-no-key.p12 b/src/tests/data/pkcs12/cert-aes256cbc-no-key.p12 new file mode 100644 index 00000000000..2550c8e317b Binary files /dev/null and b/src/tests/data/pkcs12/cert-aes256cbc-no-key.p12 differ diff --git a/src/tests/data/pkcs12/cert-key-aes256cbc.p12 b/src/tests/data/pkcs12/cert-key-aes256cbc.p12 new file mode 100644 index 00000000000..5bb25fa0b67 Binary files /dev/null and b/src/tests/data/pkcs12/cert-key-aes256cbc.p12 differ diff --git a/src/tests/data/pkcs12/cert-none-key-none.p12 b/src/tests/data/pkcs12/cert-none-key-none.p12 new file mode 100644 index 00000000000..b3f5c2a84f4 Binary files /dev/null and b/src/tests/data/pkcs12/cert-none-key-none.p12 differ diff --git a/src/tests/data/pkcs12/cert-rc2-key-3des.p12 b/src/tests/data/pkcs12/cert-rc2-key-3des.p12 new file mode 100644 index 00000000000..9041671bea2 Binary files /dev/null and b/src/tests/data/pkcs12/cert-rc2-key-3des.p12 differ diff --git a/src/tests/data/pkcs12/envelopeddata_content.pfx b/src/tests/data/pkcs12/envelopeddata_content.pfx new file mode 100644 index 00000000000..0afd0a8f75e Binary files /dev/null and b/src/tests/data/pkcs12/envelopeddata_content.pfx differ diff --git a/src/tests/data/pkcs12/java-truststore.p12 b/src/tests/data/pkcs12/java-truststore.p12 new file mode 100644 index 00000000000..02d8e7220f2 Binary files /dev/null and b/src/tests/data/pkcs12/java-truststore.p12 differ diff --git a/src/tests/data/pkcs12/key_bag_unencrypted.pfx b/src/tests/data/pkcs12/key_bag_unencrypted.pfx new file mode 100644 index 00000000000..471f5fb093c Binary files /dev/null and b/src/tests/data/pkcs12/key_bag_unencrypted.pfx differ diff --git a/src/tests/data/pkcs12/key_cert_spki_mismatch.pfx b/src/tests/data/pkcs12/key_cert_spki_mismatch.pfx new file mode 100644 index 00000000000..7e9561e145b Binary files /dev/null and b/src/tests/data/pkcs12/key_cert_spki_mismatch.pfx differ diff --git a/src/tests/data/pkcs12/name-1-no-pwd.p12 b/src/tests/data/pkcs12/name-1-no-pwd.p12 new file mode 100644 index 00000000000..72a18eedf1c Binary files /dev/null and b/src/tests/data/pkcs12/name-1-no-pwd.p12 differ diff --git a/src/tests/data/pkcs12/name-1-pwd.p12 b/src/tests/data/pkcs12/name-1-pwd.p12 new file mode 100644 index 00000000000..da279ffea95 Binary files /dev/null and b/src/tests/data/pkcs12/name-1-pwd.p12 differ diff --git a/src/tests/data/pkcs12/name-2-3-no-pwd.p12 b/src/tests/data/pkcs12/name-2-3-no-pwd.p12 new file mode 100644 index 00000000000..e66d9599454 Binary files /dev/null and b/src/tests/data/pkcs12/name-2-3-no-pwd.p12 differ diff --git a/src/tests/data/pkcs12/name-2-3-pwd.p12 b/src/tests/data/pkcs12/name-2-3-pwd.p12 new file mode 100644 index 00000000000..381dfbe9660 Binary files /dev/null and b/src/tests/data/pkcs12/name-2-3-pwd.p12 differ diff --git a/src/tests/data/pkcs12/name-2-no-pwd.p12 b/src/tests/data/pkcs12/name-2-no-pwd.p12 new file mode 100644 index 00000000000..2fb99fb0b54 Binary files /dev/null and b/src/tests/data/pkcs12/name-2-no-pwd.p12 differ diff --git a/src/tests/data/pkcs12/name-2-pwd.p12 b/src/tests/data/pkcs12/name-2-pwd.p12 new file mode 100644 index 00000000000..076390b6369 Binary files /dev/null and b/src/tests/data/pkcs12/name-2-pwd.p12 differ diff --git a/src/tests/data/pkcs12/name-3-no-pwd.p12 b/src/tests/data/pkcs12/name-3-no-pwd.p12 new file mode 100644 index 00000000000..b2b5aad2141 Binary files /dev/null and b/src/tests/data/pkcs12/name-3-no-pwd.p12 differ diff --git a/src/tests/data/pkcs12/name-3-pwd.p12 b/src/tests/data/pkcs12/name-3-pwd.p12 new file mode 100644 index 00000000000..bbe947fb805 Binary files /dev/null and b/src/tests/data/pkcs12/name-3-pwd.p12 differ diff --git a/src/tests/data/pkcs12/name-all-no-pwd.p12 b/src/tests/data/pkcs12/name-all-no-pwd.p12 new file mode 100644 index 00000000000..f16920113a6 Binary files /dev/null and b/src/tests/data/pkcs12/name-all-no-pwd.p12 differ diff --git a/src/tests/data/pkcs12/name-all-pwd.p12 b/src/tests/data/pkcs12/name-all-pwd.p12 new file mode 100644 index 00000000000..4451e5b2783 Binary files /dev/null and b/src/tests/data/pkcs12/name-all-pwd.p12 differ diff --git a/src/tests/data/pkcs12/name-unicode-no-pwd.p12 b/src/tests/data/pkcs12/name-unicode-no-pwd.p12 new file mode 100644 index 00000000000..aae136663e1 Binary files /dev/null and b/src/tests/data/pkcs12/name-unicode-no-pwd.p12 differ diff --git a/src/tests/data/pkcs12/name-unicode-pwd.p12 b/src/tests/data/pkcs12/name-unicode-pwd.p12 new file mode 100644 index 00000000000..9c554aa2147 Binary files /dev/null and b/src/tests/data/pkcs12/name-unicode-pwd.p12 differ diff --git a/src/tests/data/pkcs12/nesting_too_deep.pfx b/src/tests/data/pkcs12/nesting_too_deep.pfx new file mode 100644 index 00000000000..d5710da85ee Binary files /dev/null and b/src/tests/data/pkcs12/nesting_too_deep.pfx differ diff --git a/src/tests/data/pkcs12/no-cert-key-aes256cbc.p12 b/src/tests/data/pkcs12/no-cert-key-aes256cbc.p12 new file mode 100644 index 00000000000..7c56b450470 Binary files /dev/null and b/src/tests/data/pkcs12/no-cert-key-aes256cbc.p12 differ diff --git a/src/tests/data/pkcs12/no-cert-name-2-no-pwd.p12 b/src/tests/data/pkcs12/no-cert-name-2-no-pwd.p12 new file mode 100644 index 00000000000..dcbe9aff1a8 Binary files /dev/null and b/src/tests/data/pkcs12/no-cert-name-2-no-pwd.p12 differ diff --git a/src/tests/data/pkcs12/no-cert-name-2-pwd.p12 b/src/tests/data/pkcs12/no-cert-name-2-pwd.p12 new file mode 100644 index 00000000000..9447e24f02c Binary files /dev/null and b/src/tests/data/pkcs12/no-cert-name-2-pwd.p12 differ diff --git a/src/tests/data/pkcs12/no-cert-name-3-no-pwd.p12 b/src/tests/data/pkcs12/no-cert-name-3-no-pwd.p12 new file mode 100644 index 00000000000..a0d22772408 Binary files /dev/null and b/src/tests/data/pkcs12/no-cert-name-3-no-pwd.p12 differ diff --git a/src/tests/data/pkcs12/no-cert-name-3-pwd.p12 b/src/tests/data/pkcs12/no-cert-name-3-pwd.p12 new file mode 100644 index 00000000000..43158699005 Binary files /dev/null and b/src/tests/data/pkcs12/no-cert-name-3-pwd.p12 differ diff --git a/src/tests/data/pkcs12/no-cert-name-all-no-pwd.p12 b/src/tests/data/pkcs12/no-cert-name-all-no-pwd.p12 new file mode 100644 index 00000000000..ef3e3cec2d1 Binary files /dev/null and b/src/tests/data/pkcs12/no-cert-name-all-no-pwd.p12 differ diff --git a/src/tests/data/pkcs12/no-cert-name-all-pwd.p12 b/src/tests/data/pkcs12/no-cert-name-all-pwd.p12 new file mode 100644 index 00000000000..7e3a6326132 Binary files /dev/null and b/src/tests/data/pkcs12/no-cert-name-all-pwd.p12 differ diff --git a/src/tests/data/pkcs12/no-cert-name-unicode-no-pwd.p12 b/src/tests/data/pkcs12/no-cert-name-unicode-no-pwd.p12 new file mode 100644 index 00000000000..60caec4d6fc Binary files /dev/null and b/src/tests/data/pkcs12/no-cert-name-unicode-no-pwd.p12 differ diff --git a/src/tests/data/pkcs12/no-cert-name-unicode-pwd.p12 b/src/tests/data/pkcs12/no-cert-name-unicode-pwd.p12 new file mode 100644 index 00000000000..a57f49e595c Binary files /dev/null and b/src/tests/data/pkcs12/no-cert-name-unicode-pwd.p12 differ diff --git a/src/tests/data/pkcs12/no-cert-no-name-no-pwd.p12 b/src/tests/data/pkcs12/no-cert-no-name-no-pwd.p12 new file mode 100644 index 00000000000..a1fa2136dd2 Binary files /dev/null and b/src/tests/data/pkcs12/no-cert-no-name-no-pwd.p12 differ diff --git a/src/tests/data/pkcs12/no-cert-no-name-pwd.p12 b/src/tests/data/pkcs12/no-cert-no-name-pwd.p12 new file mode 100644 index 00000000000..c23a7615e19 Binary files /dev/null and b/src/tests/data/pkcs12/no-cert-no-name-pwd.p12 differ diff --git a/src/tests/data/pkcs12/no-name-no-pwd.p12 b/src/tests/data/pkcs12/no-name-no-pwd.p12 new file mode 100644 index 00000000000..c71d24dc26a Binary files /dev/null and b/src/tests/data/pkcs12/no-name-no-pwd.p12 differ diff --git a/src/tests/data/pkcs12/no-name-pwd.p12 b/src/tests/data/pkcs12/no-name-pwd.p12 new file mode 100644 index 00000000000..f2ac4c1a9e6 Binary files /dev/null and b/src/tests/data/pkcs12/no-name-pwd.p12 differ diff --git a/src/tests/data/pkcs12/no-password.p12 b/src/tests/data/pkcs12/no-password.p12 new file mode 100644 index 00000000000..994dabb2ab4 Binary files /dev/null and b/src/tests/data/pkcs12/no-password.p12 differ diff --git a/src/tests/data/pkcs12/openssl_3des.p12 b/src/tests/data/pkcs12/openssl_3des.p12 new file mode 100644 index 00000000000..34fa3e8a37e Binary files /dev/null and b/src/tests/data/pkcs12/openssl_3des.p12 differ diff --git a/src/tests/data/pkcs12/openssl_aes256.p12 b/src/tests/data/pkcs12/openssl_aes256.p12 new file mode 100644 index 00000000000..43a8c27a9e2 Binary files /dev/null and b/src/tests/data/pkcs12/openssl_aes256.p12 differ diff --git a/src/tests/data/pkcs12/pfx_version_2.pfx b/src/tests/data/pkcs12/pfx_version_2.pfx new file mode 100644 index 00000000000..75a48b51927 Binary files /dev/null and b/src/tests/data/pkcs12/pfx_version_2.pfx differ diff --git a/src/tests/data/pkcs12/safe_contents_bag_nested.pfx b/src/tests/data/pkcs12/safe_contents_bag_nested.pfx new file mode 100644 index 00000000000..2570b565852 Binary files /dev/null and b/src/tests/data/pkcs12/safe_contents_bag_nested.pfx differ diff --git a/src/tests/data/pkcs12/unknown_bag_crl.pfx b/src/tests/data/pkcs12/unknown_bag_crl.pfx new file mode 100644 index 00000000000..a00653c8828 Binary files /dev/null and b/src/tests/data/pkcs12/unknown_bag_crl.pfx differ diff --git a/src/tests/data/pkcs12/unknown_bag_secret.pfx b/src/tests/data/pkcs12/unknown_bag_secret.pfx new file mode 100644 index 00000000000..62d9ac5d8d2 Binary files /dev/null and b/src/tests/data/pkcs12/unknown_bag_secret.pfx differ diff --git a/src/tests/test_pkcs12.cpp b/src/tests/test_pkcs12.cpp new file mode 100644 index 00000000000..fab992b1eb1 --- /dev/null +++ b/src/tests/test_pkcs12.cpp @@ -0,0 +1,1237 @@ +/* +* PKCS#12 Tests +* (C) 2026 Damiano Mazzella +* +* Botan is released under the Simplified BSD License (see license.txt) +*/ + +#include "tests.h" + +#if defined(BOTAN_HAS_PKCS12) + #include + #include + #include + + #if defined(BOTAN_HAS_ECDSA) + #include + #include + #include + #include + #include + #include + #endif + +namespace Botan_Tests { + +namespace { + +class PKCS12_Tests final : public Test { + public: + std::vector run() override { + std::vector results; + + // Tests that only exercise parsing / bundle-shape logic and do not + // depend on any specific public-key algorithm are always run. + + results.push_back(test_empty_input()); + results.push_back(test_nesting_depth_exceeded()); + results.push_back(test_key_bag()); + results.push_back(test_safe_contents_bag()); + results.push_back(test_end_entity_without_match()); + results.push_back(test_unknown_bag_types()); + results.push_back(test_pfx_invalid_version()); + results.push_back(test_envelopeddata_rejected()); + results.push_back(test_crl_bag_unknown()); + results.push_back(test_add_null_key_rejected()); + results.push_back(test_local_key_id_empty()); + results.push_back(test_export_empty_bundle()); + + #if defined(BOTAN_HAS_ECDSA) + // The remaining tests generate ECDSA credentials on the fly and are + // therefore only compiled when ECDSA is available in this build. + + results.push_back(test_key_cert_mismatch()); + results.push_back(test_zero_iterations()); + results.push_back(test_max_iterations_exceeded()); + results.push_back(test_wrong_password()); + results.push_back(test_corrupted_pfx()); + results.push_back(test_no_mac()); + results.push_back(test_empty_password()); + results.push_back(test_cert_only()); + results.push_back(test_legacy_compat_flag()); + #if defined(BOTAN_HAS_PKCS5_PBES2) + results.push_back(test_mac_sha256()); + #endif + + results.push_back(test_basic_roundtrip()); + results.push_back(test_roundtrip_with_chain()); + results.push_back(test_no_friendly_name()); + results.push_back(test_custom_iterations()); + + results.push_back(test_builder_workflow()); + results.push_back(test_export_options_fluent()); + results.push_back(test_multiple_keys()); + results.push_back(test_clear_friendly_name()); + results.push_back(test_local_key_id_setters()); + results.push_back(test_options_friendly_name_override()); + + results.push_back(test_duplicate_certificate()); + results.push_back(test_friendly_name_non_bmp()); + results.push_back(test_trailing_data()); + + results.push_back(test_mac_digest("SHA-224")); + #if defined(BOTAN_HAS_SHA2_64) + results.push_back(test_mac_digest("SHA-384")); + results.push_back(test_mac_digest("SHA-512")); + results.push_back(test_mac_digest("SHA-512-256")); + #endif + + results.push_back(test_key_encryption("PBE-SHA1-3DES")); + results.push_back(test_key_encryption("PBE-SHA1-2DES")); + #if defined(BOTAN_HAS_PKCS5_PBES2) + results.push_back(test_key_encryption("PBES2-SHA256-AES256")); + results.push_back(test_key_encryption("PBES2-SHA256-AES128")); + #endif + + results.push_back(test_cert_encryption("")); // Unencrypted certs + results.push_back(test_cert_encryption("PBE-SHA1-3DES")); + results.push_back(test_cert_encryption("PBE-SHA1-2DES")); + #if defined(BOTAN_HAS_PKCS5_PBES2) + results.push_back(test_cert_encryption("PBES2-SHA256-AES256")); + results.push_back(test_cert_encryption("PBES2-SHA256-AES128")); + + results.push_back(test_mixed_encryption("PBES2-SHA256-AES256", "PBE-SHA1-3DES")); + results.push_back(test_mixed_encryption("PBE-SHA1-3DES", "PBES2-SHA256-AES128")); + + results.push_back(test_chain_with_pbes2()); + #endif + #endif + + // External file parsing tests (do not depend on ECDSA at compile time + // -- the parser surfaces whatever key type the file contains). + results.push_back(test_parse_file("openssl_3des.p12", "test123")); + results.push_back(test_parse_file("cert-none-key-none.p12", "cryptography")); + results.push_back(test_parse_file("name-1-pwd.p12", "password", true, true, true, true)); + results.push_back(test_parse_file("name-2-3-pwd.p12", "password", true, true, true, true)); + results.push_back(test_parse_file("name-2-pwd.p12", "password", true, true, true, true)); + results.push_back(test_parse_file("name-3-pwd.p12", "password", true, true, true, true)); + results.push_back(test_parse_file("name-all-pwd.p12", "password", true, true, true, true)); + results.push_back(test_parse_file("name-unicode-pwd.p12", "password", true, true, true, true)); + results.push_back(test_parse_file("no-cert-name-2-pwd.p12", "password", true, false, true, true)); + results.push_back(test_parse_file("no-cert-name-3-pwd.p12", "password", true, false, true, true)); + results.push_back(test_parse_file("no-cert-name-all-pwd.p12", "password", true, false, true, true)); + results.push_back(test_parse_file("no-cert-name-unicode-pwd.p12", "password", true, false, true, true)); + results.push_back(test_parse_file("no-cert-no-name-pwd.p12", "password", true, false, true, false)); + results.push_back(test_parse_file("no-name-pwd.p12", "password", true, true, true)); + results.push_back(test_parse_file("java-truststore.p12", "", true, false, true, true)); + // OpenSSL-generated files with an empty password: these use OpenSSL's + // non-conforming empty-password encoding (empty byte string instead of + // RFC 7292's {0x00,0x00}). The parser transparently falls back to it. + results.push_back(test_parse_file("name-1-no-pwd.p12", "", true, true, true, true)); + results.push_back(test_parse_file("name-2-3-no-pwd.p12", "", true, true, true, true)); + results.push_back(test_parse_file("name-2-no-pwd.p12", "", true, true, true, true)); + results.push_back(test_parse_file("no-cert-no-name-no-pwd.p12", "", true, false, true, false)); + results.push_back(test_parse_file("no-name-no-pwd.p12", "", true, true, true, false)); + results.push_back(test_parse_file("name-3-no-pwd.p12", "", true, true, true, true)); + results.push_back(test_parse_file("no-cert-name-all-no-pwd.p12", "", true, false, true, true)); + results.push_back(test_parse_file("name-all-no-pwd.p12", "", true, true, true, true)); + results.push_back(test_parse_file("name-unicode-no-pwd.p12", "", true, true, true, true)); + results.push_back(test_parse_file("no-cert-name-2-no-pwd.p12", "", true, false, true, true)); + results.push_back(test_parse_file("no-cert-name-3-no-pwd.p12", "", true, false, true, true)); + results.push_back(test_parse_file("no-cert-name-unicode-no-pwd.p12", "", true, false, true, true)); + // OpenSSL empty password (RC2 branch -- key encryption not supported, + // so parsing still fails, but with Decoding_Error, not auth-tag). + results.push_back(test_parse_file_unsupported_algorithm("no-password.p12", "")); + // Wrong password + results.push_back(test_parse_file_wrong_password("name-1-pwd.p12", "wrongpassword")); + // RC2 unsupported algorithm + results.push_back(test_parse_file_unsupported_algorithm("cert-rc2-key-3des.p12", "cryptography")); + #if defined(BOTAN_HAS_PKCS5_PBES2) + results.push_back(test_parse_file("openssl_aes256.p12", "test123")); + results.push_back(test_parse_file("cert-aes256cbc-no-key.p12", "cryptography", true, false)); + results.push_back(test_parse_file("cert-key-aes256cbc.p12", "cryptography")); + results.push_back(test_parse_file("no-cert-key-aes256cbc.p12", "cryptography", false, true)); + #endif + + return results; + } + + private: + #if defined(BOTAN_HAS_ECDSA) + // Helper to generate a test key and certificate + struct TestCredentials { + std::shared_ptr key; + Botan::X509_Certificate cert; + }; + + TestCredentials generate_credentials(Botan::RandomNumberGenerator& rng, + const std::string& cn = "Test Certificate") { + TestCredentials creds; + creds.key = std::make_shared(rng, Botan::EC_Group::from_name("secp256r1")); + + Botan::X509_Cert_Options opts; + opts.common_name = cn; + opts.country = "US"; + opts.dns = "localhost"; + + creds.cert = Botan::X509::create_self_signed_cert(opts, *creds.key, "SHA-256", rng); + return creds; + } + + // Verify parsed PKCS12 data matches original + void verify_parsed_data(Test::Result& result, + const Botan::PKCS12& parsed, + const Botan::ECDSA_PrivateKey& orig_key, + const Botan::X509_Certificate& orig_cert, + const std::string& expected_friendly_name = "") { + result.test_is_true("Has private key", !parsed.private_keys().empty()); + result.test_is_true("Has certificate", parsed.end_entity_certificate().has_value()); + + if(!expected_friendly_name.empty()) { + result.test_str_eq("Friendly name", parsed.friendly_name().value_or(""), expected_friendly_name); + } + + if(!parsed.private_keys().empty()) { + result.test_str_eq("Key algorithm", parsed.private_keys().front()->algo_name(), "ECDSA"); + const auto parsed_bits = parsed.private_keys().front()->private_key_bits(); + const auto orig_bits = orig_key.private_key_bits(); + result.test_is_true("Key matches", parsed_bits == orig_bits); + } + + if(parsed.end_entity_certificate()) { + result.test_is_true("Certificate matches", + parsed.end_entity_certificate()->BER_encode() == orig_cert.BER_encode()); + } + } + + Test::Result test_key_cert_mismatch() { + Test::Result result("PKCS12 key-cert mismatch rejected"); + + auto rng = Test::new_rng("PKCS12_mismatch"); + const auto creds = generate_credentials(*rng); + const auto other = generate_credentials(*rng, "Other"); + + result.test_throws("mismatched key and certificate throws", [&]() { + Botan::PKCS12 bundle; + bundle.add_key(other.key); + bundle.add_certificate(creds.cert); + (void)bundle.export_to(Botan::PKCS12_Export_Options::legacy_compat("testpassword"), *rng); + }); + + return result; + } + + Test::Result test_basic_roundtrip() { + Test::Result result("PKCS12 basic roundtrip"); + + auto rng = Test::new_rng("PKCS12_basic"); + auto creds = generate_credentials(*rng); + + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + bundle.set_friendly_name("Basic Test Key"); + const std::vector pfx = + bundle.export_to(Botan::PKCS12_Export_Options::legacy_compat("testpassword"), *rng); + result.test_sz_gt("PFX data generated", pfx.size(), 0); + + const Botan::PKCS12 parsed(pfx, "testpassword"); + verify_parsed_data(result, parsed, *creds.key, creds.cert, "Basic Test Key"); + + return result; + } + + Test::Result test_roundtrip_with_chain() { + Test::Result result("PKCS12 roundtrip with certificate chain"); + + auto rng = Test::new_rng("PKCS12_chain"); + + // Generate CA + const auto ca_key = std::make_shared(*rng, Botan::EC_Group::from_name("secp256r1")); + Botan::X509_Cert_Options ca_opts; + ca_opts.common_name = "Test CA"; + ca_opts.country = "US"; + ca_opts.CA_key(); + const Botan::X509_Certificate ca_cert = + Botan::X509::create_self_signed_cert(ca_opts, *ca_key, "SHA-256", *rng); + + // Create CA signer + const Botan::X509_CA ca(ca_cert, *ca_key, "SHA-256", *rng); + + // Generate end-entity + const auto ee_key = std::make_shared(*rng, Botan::EC_Group::from_name("secp256r1")); + Botan::X509_Cert_Options ee_opts; + ee_opts.common_name = "End Entity"; + ee_opts.country = "US"; + ee_opts.dns = "localhost"; + + const Botan::PKCS10_Request csr = Botan::X509::create_cert_req(ee_opts, *ee_key, "SHA-256", *rng); + const Botan::X509_Certificate ee_cert = + ca.sign_request(csr, *rng, Botan::X509_Time("200101000000Z"), Botan::X509_Time("300101000000Z")); + + // Create PKCS#12 with chain + const auto opts = Botan::PKCS12_Export_Options::modern("chaintest"); + const std::vector chain = {ca_cert}; + Botan::PKCS12 bundle; + bundle.add_key(ee_key); + bundle.add_certificate(ee_cert); + for(const auto& chain_cert : chain) { + bundle.add_certificate(chain_cert); + } + bundle.set_friendly_name("Chain Test Key"); + const std::vector pfx = bundle.export_to(opts, *rng); + result.test_sz_gt("PFX data generated", pfx.size(), 0); + + const Botan::PKCS12 parsed(pfx, "chaintest"); + + result.test_is_true("Has private key", !parsed.private_keys().empty()); + result.test_is_true("Has certificate", parsed.end_entity_certificate().has_value()); + result.test_sz_eq("CA certificates count", parsed.ca_certificates().size(), 1); + result.test_str_eq("Friendly name", parsed.friendly_name().value_or(""), "Chain Test Key"); + + // Verify CA cert in chain + if(!parsed.ca_certificates().empty()) { + result.test_is_true("CA cert matches", parsed.ca_certificates()[0].BER_encode() == ca_cert.BER_encode()); + } + + return result; + } + + Test::Result test_no_friendly_name() { + Test::Result result("PKCS12 without friendly name"); + + auto rng = Test::new_rng("PKCS12_no_name"); + auto creds = generate_credentials(*rng); + + // No friendly_name set on bundle or in options. + const auto opts = Botan::PKCS12_Export_Options::legacy_compat("noname"); + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + const std::vector pfx = bundle.export_to(opts, *rng); + result.test_sz_gt("PFX data generated", pfx.size(), 0); + + const Botan::PKCS12 parsed(pfx, "noname"); + + result.test_is_true("Has private key", !parsed.private_keys().empty()); + result.test_is_true("Has certificate", parsed.end_entity_certificate().has_value()); + result.test_is_true("No friendly name", !parsed.friendly_name().has_value()); + + return result; + } + + Test::Result test_custom_iterations() { + Test::Result result("PKCS12 with custom iterations"); + + auto rng = Test::new_rng("PKCS12_iterations"); + auto creds = generate_credentials(*rng); + + // Test with non-default iteration count + const auto opts = Botan::PKCS12_Export_Options::legacy_compat("itertest").with_iterations(5000); + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + const std::vector pfx = bundle.export_to(opts, *rng); + result.test_sz_gt("PFX data generated", pfx.size(), 0); + + const Botan::PKCS12 parsed(pfx, "itertest"); + verify_parsed_data(result, parsed, *creds.key, creds.cert); + + return result; + } + + Test::Result test_key_encryption(const std::string& algo) { + Test::Result result("PKCS12 key encryption: " + algo); + + auto rng = Test::new_rng("PKCS12_key_enc_" + algo); + auto creds = generate_credentials(*rng); + + const auto opts = + Botan::PKCS12_Export_Options("keyenctest").with_key_encryption_algo(algo).with_iterations(2048); + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + bundle.set_friendly_name("Key Enc Test"); + const std::vector pfx = bundle.export_to(opts, *rng); + result.test_sz_gt("PFX data generated", pfx.size(), 0); + + const Botan::PKCS12 parsed(pfx, "keyenctest"); + verify_parsed_data(result, parsed, *creds.key, creds.cert, "Key Enc Test"); + + return result; + } + + Test::Result test_cert_encryption(const std::string& algo) { + const std::string test_name = + algo.empty() ? "PKCS12 cert encryption: unencrypted" : "PKCS12 cert encryption: " + algo; + Test::Result result(test_name); + + auto rng = Test::new_rng("PKCS12_cert_enc_" + (algo.empty() ? "none" : algo)); + auto creds = generate_credentials(*rng); + + auto opts = Botan::PKCS12_Export_Options("certenctest").with_iterations(2048); + if(!algo.empty()) { + opts.with_cert_encryption_algo(algo); + } + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + bundle.set_friendly_name("Cert Enc Test"); + const std::vector pfx = bundle.export_to(opts, *rng); + result.test_sz_gt("PFX data generated", pfx.size(), 0); + + const Botan::PKCS12 parsed(pfx, "certenctest"); + verify_parsed_data(result, parsed, *creds.key, creds.cert, "Cert Enc Test"); + + return result; + } + + Test::Result test_mixed_encryption(const std::string& key_algo, const std::string& cert_algo) { + Test::Result result("PKCS12 mixed encryption: key=" + key_algo + " cert=" + cert_algo); + + auto rng = Test::new_rng("PKCS12_mixed_" + key_algo + "_" + cert_algo); + auto creds = generate_credentials(*rng); + + const auto opts = Botan::PKCS12_Export_Options("mixedtest") + .with_key_encryption_algo(key_algo) + .with_cert_encryption_algo(cert_algo) + .with_iterations(2048); + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + bundle.set_friendly_name("Mixed Enc Test"); + const std::vector pfx = bundle.export_to(opts, *rng); + result.test_sz_gt("PFX data generated", pfx.size(), 0); + + const Botan::PKCS12 parsed(pfx, "mixedtest"); + verify_parsed_data(result, parsed, *creds.key, creds.cert, "Mixed Enc Test"); + + return result; + } + + Test::Result test_chain_with_pbes2() { + Test::Result result("PKCS12 chain with PBES2 encryption"); + + auto rng = Test::new_rng("PKCS12_chain_pbes2"); + + // Generate CA + const auto ca_key = std::make_shared(*rng, Botan::EC_Group::from_name("secp256r1")); + Botan::X509_Cert_Options ca_opts; + ca_opts.common_name = "PBES2 CA"; + ca_opts.country = "US"; + ca_opts.CA_key(); + const Botan::X509_Certificate ca_cert = + Botan::X509::create_self_signed_cert(ca_opts, *ca_key, "SHA-256", *rng); + + // Create intermediate CA + const Botan::X509_CA root_ca(ca_cert, *ca_key, "SHA-256", *rng); + + const auto int_key = std::make_shared(*rng, Botan::EC_Group::from_name("secp256r1")); + Botan::X509_Cert_Options int_opts; + int_opts.common_name = "Intermediate CA"; + int_opts.country = "US"; + int_opts.CA_key(); + + const Botan::PKCS10_Request int_csr = Botan::X509::create_cert_req(int_opts, *int_key, "SHA-256", *rng); + const Botan::X509_Certificate int_cert = + root_ca.sign_request(int_csr, *rng, Botan::X509_Time("200101000000Z"), Botan::X509_Time("300101000000Z")); + + // Create end-entity cert signed by intermediate + const Botan::X509_CA int_ca(int_cert, *int_key, "SHA-256", *rng); + + const auto ee_key = std::make_shared(*rng, Botan::EC_Group::from_name("secp256r1")); + Botan::X509_Cert_Options ee_opts; + ee_opts.common_name = "End Entity"; + ee_opts.country = "US"; + ee_opts.dns = "localhost"; + + const Botan::PKCS10_Request ee_csr = Botan::X509::create_cert_req(ee_opts, *ee_key, "SHA-256", *rng); + const Botan::X509_Certificate ee_cert = + int_ca.sign_request(ee_csr, *rng, Botan::X509_Time("200101000000Z"), Botan::X509_Time("300101000000Z")); + + // Create PKCS#12 with full chain and PBES2 + const auto opts = Botan::PKCS12_Export_Options("chainpbes2") + .with_key_encryption_algo("PBES2-SHA256-AES256") + .with_cert_encryption_algo("PBES2-SHA256-AES128") + .with_iterations(10000); + + const std::vector chain = {int_cert, ca_cert}; + Botan::PKCS12 bundle; + bundle.add_key(ee_key); + bundle.add_certificate(ee_cert); + for(const auto& ca : chain) { + bundle.add_certificate(ca); + } + bundle.set_friendly_name("Chain PBES2 Key"); + const std::vector pfx = bundle.export_to(opts, *rng); + result.test_sz_gt("PFX data generated", pfx.size(), 0); + + const Botan::PKCS12 parsed(pfx, "chainpbes2"); + + result.test_is_true("Has private key", !parsed.private_keys().empty()); + result.test_is_true("Has certificate", parsed.end_entity_certificate().has_value()); + result.test_sz_eq("CA certificates count", parsed.ca_certificates().size(), 2); + result.test_str_eq("Friendly name", parsed.friendly_name().value_or(""), "Chain PBES2 Key"); + + // Verify key + if(!parsed.private_keys().empty()) { + const auto parsed_bits = parsed.private_keys().front()->private_key_bits(); + const auto orig_bits = ee_key->private_key_bits(); + result.test_is_true("Key matches", parsed_bits == orig_bits); + } + + // Verify end-entity cert + if(parsed.end_entity_certificate()) { + result.test_is_true("EE cert matches", + parsed.end_entity_certificate()->BER_encode() == ee_cert.BER_encode()); + } + + return result; + } + #endif + + Test::Result test_parse_file_wrong_password(const std::string& filename, const std::string& password = "") { + Test::Result result("PKCS12 parse file wrong password: " + filename); + + try { + const std::vector pfx_data = Test::read_binary_data_file("pkcs12/" + filename); + result.test_throws("wrong password throws", + [&]() { Botan::PKCS12(pfx_data, password); }); + } catch(const std::exception& e) { + result.test_failure("Failed to read PFX file", e.what()); + } + + return result; + } + + Test::Result test_parse_file_unsupported_algorithm(const std::string& filename, + const std::string& password = "") { + Test::Result result("PKCS12 parse file unsupported algorithm: " + filename); + + try { + const std::vector pfx_data = Test::read_binary_data_file("pkcs12/" + filename); + result.test_throws("unsupported algorithm throws", + [&]() { Botan::PKCS12(pfx_data, password); }); + } catch(const std::exception& e) { + result.test_failure("Failed to read PFX file", e.what()); + } + + return result; + } + + Test::Result test_parse_file(const std::string& filename, + const std::string& password = "", + bool has_cert = true, + bool has_key = true, + bool has_ca = false, + bool has_friendly_name = false, + const std::string& expected_friendly_name = "") { + Test::Result result("PKCS12 parse file: " + filename); + + try { + const std::vector pfx_data = Test::read_binary_data_file("pkcs12/" + filename); + const Botan::PKCS12 parsed(pfx_data, password); + + // has_cert here means "bundle contains at least one certificate" + // (not specifically an end-entity); has_ca means "bundle contains + // certificates other than the end-entity". + result.test_is_true("Has private key", !parsed.private_keys().empty() == has_key); + result.test_is_true("Has certificate", !parsed.certificates().empty() == has_cert); + result.test_is_true("Has CA certificates", !parsed.ca_certificates().empty() == has_ca); + result.test_is_true("Has friendly name", parsed.friendly_name().has_value() == has_friendly_name); + if(has_key && !parsed.private_keys().empty()) { + result.test_str_not_empty("Key algorithm", parsed.private_keys().front()->algo_name()); + } + + if(has_cert && !parsed.certificates().empty()) { + result.test_str_not_empty("Certificate CN", parsed.certificates().front().subject_info("CN").at(0)); + } + + if(has_ca && !parsed.ca_certificates().empty()) { + for(size_t i = 0; i < parsed.ca_certificates().size(); ++i) { + result.test_str_not_empty("CA certificate " + std::to_string(i) + " CN", + parsed.ca_certificates()[i].subject_info("CN").at(0)); + } + } + + if(has_friendly_name && parsed.friendly_name().has_value()) { + result.test_str_not_empty("Friendly name", *parsed.friendly_name()); + if(!expected_friendly_name.empty()) { + result.test_str_eq("Friendly name value", *parsed.friendly_name(), expected_friendly_name); + } + } + + result.test_success("Parsed PFX successfully"); + } catch(const std::exception& e) { + result.test_failure("Failed to parse PFX", e.what()); + } + + return result; + } + #if defined(BOTAN_HAS_ECDSA) + + Test::Result test_wrong_password() { + Test::Result result("PKCS12 wrong password rejected"); + + auto rng = Test::new_rng("PKCS12_wrong_pass"); + auto creds = generate_credentials(*rng); + + const auto opts = Botan::PKCS12_Export_Options::legacy_compat("correct"); + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + const auto pfx = bundle.export_to(opts, *rng); + + result.test_throws("wrong password throws", + [&]() { Botan::PKCS12(pfx, "wrong"); }); + + return result; + } + + Test::Result test_corrupted_pfx() { + Test::Result result("PKCS12 corrupted data rejected"); + + auto rng = Test::new_rng("PKCS12_corrupt"); + auto creds = generate_credentials(*rng); + + const auto opts = Botan::PKCS12_Export_Options::legacy_compat("test"); + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + auto pfx = bundle.export_to(opts, *rng); + + // Corrupt bytes in the middle of the data + pfx[pfx.size() / 2] ^= 0xFF; + + result.test_throws("corrupted pfx throws", [&]() { Botan::PKCS12(pfx, "test"); }); + + return result; + } + + Test::Result test_no_mac() { + Test::Result result("PKCS12 without MAC"); + + auto rng = Test::new_rng("PKCS12_no_mac"); + auto creds = generate_credentials(*rng); + + const auto opts = Botan::PKCS12_Export_Options::legacy_compat("nomactest").without_mac(); + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + const auto pfx = bundle.export_to(opts, *rng); + result.test_sz_gt("PFX data generated", pfx.size(), 0); + + const auto parsed = Botan::PKCS12(pfx, "nomactest"); + + result.test_is_true("Has private key", !parsed.private_keys().empty()); + result.test_is_true("Has certificate", parsed.end_entity_certificate().has_value()); + + return result; + } + + Test::Result test_empty_password() { + Test::Result result("PKCS12 empty password roundtrip"); + + auto rng = Test::new_rng("PKCS12_empty_pass"); + auto creds = generate_credentials(*rng); + + // Empty password requires include_mac=false. + const auto opts = Botan::PKCS12_Export_Options::legacy_compat("").without_mac(); + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + const auto pfx = bundle.export_to(opts, *rng); + result.test_sz_gt("PFX data generated", pfx.size(), 0); + + const auto parsed = Botan::PKCS12(pfx, ""); + result.test_is_true("Has private key", !parsed.private_keys().empty()); + result.test_is_true("Has certificate", parsed.end_entity_certificate().has_value()); + + return result; + } + + Test::Result test_cert_only() { + Test::Result result("PKCS12 cert-only bundle"); + + auto rng = Test::new_rng("PKCS12_cert_only"); + auto creds = generate_credentials(*rng); + + const auto opts = Botan::PKCS12_Export_Options::legacy_compat("certonly"); + Botan::PKCS12 bundle; + bundle.add_certificate(creds.cert); + const auto pfx = bundle.export_to(opts, *rng); + result.test_sz_gt("PFX data generated", pfx.size(), 0); + + const auto parsed = Botan::PKCS12(pfx, "certonly"); + result.test_is_false("No private key", !parsed.private_keys().empty()); + result.test_is_true("Has certificate", !parsed.certificates().empty()); + result.test_is_false("No end-entity (no key)", parsed.end_entity_certificate().has_value()); + + return result; + } + #endif + + Test::Result test_empty_input() { + Test::Result result("PKCS12 empty input rejected"); + + result.test_throws("empty bytes throws", + []() { Botan::PKCS12(std::span{}, "pass"); }); + + return result; + } + #if defined(BOTAN_HAS_ECDSA) + + Test::Result test_zero_iterations() { + Test::Result result("PKCS12 zero iterations rejected"); + + auto rng = Test::new_rng("PKCS12_zero_iter"); + auto creds = generate_credentials(*rng); + + const auto opts = Botan::PKCS12_Export_Options("test").with_iterations(0); + + result.test_throws("zero iterations throws", [&]() { + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + (void)bundle.export_to(opts, *rng); + }); + + return result; + } + + Test::Result test_max_iterations_exceeded() { + Test::Result result("PKCS12 max iterations exceeded rejected"); + + auto rng = Test::new_rng("PKCS12_max_iter"); + auto creds = generate_credentials(*rng); + + // PKCS12_MAX_ITERATIONS + 1 + const auto opts = Botan::PKCS12_Export_Options("test").with_iterations(1'000'001); + + result.test_throws("max iterations exceeded throws", [&]() { + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + (void)bundle.export_to(opts, *rng); + }); + + return result; + } + #endif + + Test::Result test_nesting_depth_exceeded() { + Test::Result result("PKCS12 nesting depth exceeded rejected"); + const auto pfx = Test::read_binary_data_file("pkcs12/nesting_too_deep.pfx"); + result.test_throws("nesting too deep throws", [&]() { Botan::PKCS12(pfx, ""); }); + return result; + } + #if defined(BOTAN_HAS_ECDSA) + + Test::Result test_legacy_compat_flag() { + Test::Result result("PKCS12 legacy_compat flag"); + + auto rng = Test::new_rng("PKCS12_legacy_compat"); + auto creds = generate_credentials(*rng); + + // legacy_compat should produce a file readable with "old" defaults + const auto opts = Botan::PKCS12_Export_Options::legacy_compat("legacytest"); + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + const auto pfx = bundle.export_to(opts, *rng); + result.test_sz_gt("PFX data generated", pfx.size(), 0); + + const auto parsed = Botan::PKCS12(pfx, "legacytest"); + result.test_is_true("Has private key", !parsed.private_keys().empty()); + result.test_is_true("Has certificate", parsed.end_entity_certificate().has_value()); + + return result; + } + #endif + + // Build a PFX (no MAC) with a KeyBag containing an unencrypted private key + // Parse a PFX containing an unencrypted KeyBag (RFC 7292 sec.4.2.1) -- + // rarely seen in the wild but explicitly allowed by the spec. + Test::Result test_key_bag() { + Test::Result result("PKCS12 KeyBag (unencrypted key)"); + const auto pfx = Test::read_binary_data_file("pkcs12/key_bag_unencrypted.pfx"); + const Botan::PKCS12 parsed(pfx, ""); + result.test_sz_eq("One private key", parsed.private_keys().size(), 1); + result.test_is_true("End-entity present", parsed.end_entity_certificate().has_value()); + return result; + } + + // Parse a PFX with a SafeContentsBag wrapping a CertBag (RFC 7292 sec.4.2.6). + Test::Result test_safe_contents_bag() { + Test::Result result("PKCS12 SafeContentsBag (nested)"); + const auto pfx = Test::read_binary_data_file("pkcs12/safe_contents_bag_nested.pfx"); + const Botan::PKCS12 parsed(pfx, ""); + result.test_is_false("No private key", !parsed.private_keys().empty()); + result.test_is_true("Has certificate", !parsed.certificates().empty()); + return result; + } + #if defined(BOTAN_HAS_ECDSA) + + // Exercise the builder workflow: + // default ctor -> add_key / add_certificate / set_friendly_name -> export_to + // -> re-parse and verify. + Test::Result test_builder_workflow() { + Test::Result result("PKCS12 builder workflow (new API)"); + + auto rng = Test::new_rng("PKCS12_builder"); + auto creds = generate_credentials(*rng, "Builder Test"); + + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + bundle.set_friendly_name("Builder Bundle"); + + const auto opts = Botan::PKCS12_Export_Options::modern("builderpw"); + const auto pfx = bundle.export_to(opts, *rng); + result.test_sz_gt("PFX data generated", pfx.size(), 0); + + const Botan::PKCS12 parsed(pfx, "builderpw"); + result.test_sz_eq("One private key", parsed.private_keys().size(), 1); + result.test_sz_eq("One certificate", parsed.certificates().size(), 1); + result.test_is_true("End-entity present", parsed.end_entity_certificate().has_value()); + result.test_is_true("Friendly name present", parsed.friendly_name().has_value()); + if(parsed.friendly_name().has_value()) { + result.test_str_eq("Friendly name value", *parsed.friendly_name(), "Builder Bundle"); + } + if(!parsed.private_keys().empty()) { + result.test_is_true("Key matches", + parsed.private_keys().front()->private_key_bits() == creds.key->private_key_bits()); + } + if(parsed.end_entity_certificate()) { + result.test_is_true("Certificate matches", + parsed.end_entity_certificate()->BER_encode() == creds.cert.BER_encode()); + } + + return result; + } + + // Verify the chainable PKCS12_Export_Options builder produces an + // equivalent file regardless of which mutator combination is used. + Test::Result test_export_options_fluent() { + Test::Result result("PKCS12_Export_Options fluent API"); + + auto rng = Test::new_rng("PKCS12_fluent"); + auto creds = generate_credentials(*rng); + + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + + // Chain several with_* mutators; each returns *this. + auto opts = Botan::PKCS12_Export_Options("fluentpw") + .with_friendly_name("Fluent Key") + .with_iterations(2048) + .with_key_encryption_algo("PBE-SHA1-3DES") + .with_mac_digest("SHA-1"); + + const auto pfx = bundle.export_to(opts, *rng); + result.test_sz_gt("PFX generated", pfx.size(), 0); + + const Botan::PKCS12 parsed(pfx, "fluentpw"); + result.test_is_true("Key parsed", !parsed.private_keys().empty()); + result.test_is_true("End-entity parsed", parsed.end_entity_certificate().has_value()); + result.test_is_true("Friendly name parsed", parsed.friendly_name().has_value()); + if(parsed.friendly_name().has_value()) { + result.test_str_eq("Friendly name from options", *parsed.friendly_name(), "Fluent Key"); + } + + // without_mac() returns *this and disables the MAC. The parser must + // accept the file without a MAC trailer. + auto opts_no_mac = Botan::PKCS12_Export_Options::legacy_compat("nomacpw").without_mac().with_iterations(2048); + const auto pfx_no_mac = bundle.export_to(opts_no_mac, *rng); + result.test_sz_gt("PFX (no MAC) generated", pfx_no_mac.size(), 0); + const Botan::PKCS12 parsed_no_mac(pfx_no_mac, "nomacpw"); + result.test_is_true("Key parsed (no MAC)", !parsed_no_mac.private_keys().empty()); + + return result; + } + #endif + + // export_to on an empty bundle must throw. + Test::Result test_export_empty_bundle() { + Test::Result result("PKCS12 export of empty bundle rejected"); + + auto rng = Test::new_rng("PKCS12_empty_export"); + Botan::PKCS12 bundle; + + result.test_throws( + "empty bundle throws", [&]() { (void)bundle.export_to(Botan::PKCS12_Export_Options::modern("pw"), *rng); }); + + return result; + } + #if defined(BOTAN_HAS_ECDSA) + + // Build a bundle with two private keys; verify both survive the + // export/parse roundtrip. PKCS#12 supports multiple key bags per file. + // The end-entity attribute (localKeyId, friendly name) is anchored to + // the first key/cert pair only by this implementation. + Test::Result test_multiple_keys() { + Test::Result result("PKCS12 multiple private keys"); + + auto rng = Test::new_rng("PKCS12_multi_key"); + auto creds1 = generate_credentials(*rng, "Primary"); + auto creds2 = generate_credentials(*rng, "Secondary"); + + // Clone both keys via PKCS#8 so the bundle owns its own copies. + auto clone_key = [](const Botan::Private_Key& k) { + Botan::DataSource_Memory s(Botan::PKCS8::BER_encode(k)); + return std::shared_ptr(Botan::PKCS8::load_key(s)); + }; + + Botan::PKCS12 bundle; + bundle.add_key(clone_key(*creds1.key)); + bundle.add_key(clone_key(*creds2.key)); + bundle.add_certificate(creds1.cert); + bundle.add_certificate(creds2.cert); + + const auto opts = Botan::PKCS12_Export_Options::modern("multipw", "Multi Key Bundle"); + const auto pfx = bundle.export_to(opts, *rng); + result.test_sz_gt("PFX generated", pfx.size(), 0); + + const Botan::PKCS12 parsed(pfx, "multipw"); + result.test_sz_eq("Two private keys", parsed.private_keys().size(), 2); + result.test_sz_eq("Two certificates", parsed.certificates().size(), 2); + result.test_is_true("End-entity present (paired with first key)", parsed.end_entity_certificate().has_value()); + + // The first parsed key should match one of the originals; same for + // the second. Order isn't guaranteed across producers, so compare + // the SET of SubjectPublicKeyInfo encodings. + std::vector> orig_spki = {creds1.key->subject_public_key(), + creds2.key->subject_public_key()}; + std::vector> parsed_spki; + for(const auto& k : parsed.private_keys()) { + parsed_spki.push_back(k->subject_public_key()); + } + std::sort(orig_spki.begin(), orig_spki.end()); + std::sort(parsed_spki.begin(), parsed_spki.end()); + result.test_is_true("Both keys' public material round-tripped", parsed_spki == orig_spki); + + return result; + } + + // clear_friendly_name() must remove a previously set friendly name + // and the exported PFX must then carry no friendly-name attribute. + Test::Result test_clear_friendly_name() { + Test::Result result("PKCS12 clear_friendly_name"); + + auto rng = Test::new_rng("PKCS12_clear_fn"); + auto creds = generate_credentials(*rng); + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + bundle.set_friendly_name("Will Be Cleared"); + result.test_is_true("Friendly name set", bundle.friendly_name().has_value()); + bundle.clear_friendly_name(); + result.test_is_false("Friendly name cleared", bundle.friendly_name().has_value()); + + const auto pfx = bundle.export_to(Botan::PKCS12_Export_Options::modern("clearpw"), *rng); + const Botan::PKCS12 parsed(pfx, "clearpw"); + result.test_is_false("No friendly name in parsed PFX", parsed.friendly_name().has_value()); + + return result; + } + + // set_local_key_id() / clear_local_key_id() must round-trip the + // attribute bytes verbatim through the PFX. + Test::Result test_local_key_id_setters() { + Test::Result result("PKCS12 set_local_key_id / clear_local_key_id"); + + auto rng = Test::new_rng("PKCS12_lki"); + auto creds = generate_credentials(*rng); + const std::vector custom_id = {0xDE, 0xAD, 0xBE, 0xEF, 0x42}; + + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + bundle.set_local_key_id(custom_id); + result.test_is_true("Local key id set", bundle.local_key_id().has_value()); + if(bundle.local_key_id()) { + result.test_is_true("Local key id matches", *bundle.local_key_id() == custom_id); + } + + const auto pfx = bundle.export_to(Botan::PKCS12_Export_Options::modern("lkipw"), *rng); + const Botan::PKCS12 parsed(pfx, "lkipw"); + result.test_is_true("Local key id present in parsed PFX", parsed.local_key_id().has_value()); + if(parsed.local_key_id()) { + result.test_is_true("Local key id round-tripped", *parsed.local_key_id() == custom_id); + } + + // clear_local_key_id removes the explicit value (export then falls + // back to deriving the id from the cert's SPKI BIT STRING). + bundle.clear_local_key_id(); + result.test_is_false("Local key id cleared", bundle.local_key_id().has_value()); + const auto pfx2 = bundle.export_to(Botan::PKCS12_Export_Options::modern("lkipw"), *rng); + const Botan::PKCS12 parsed2(pfx2, "lkipw"); + result.test_is_true("Local key id auto-derived from cert", parsed2.local_key_id().has_value()); + + return result; + } + #endif + + // A parsed bundle with a key whose SPKI doesn't match any of the + // included certificates must report end_entity_certificate() == nullopt + // while still surfacing both the key and the cert(s). + // A parsed bundle whose (single) key SPKI doesn't match the (single) + // certificate: end_entity_certificate() must be nullopt while both are + // surfaced by private_keys() / certificates(). + Test::Result test_end_entity_without_match() { + Test::Result result("PKCS12 end_entity_certificate nullopt when no match"); + const auto pfx = Test::read_binary_data_file("pkcs12/key_cert_spki_mismatch.pfx"); + const Botan::PKCS12 parsed(pfx, ""); + result.test_is_true("Key parsed", !parsed.private_keys().empty()); + result.test_is_true("Cert parsed", !parsed.certificates().empty()); + result.test_is_false("No end-entity (SPKI mismatch)", parsed.end_entity_certificate().has_value()); + // ca_certificates() falls back to "all but first" when there is no + // end-entity; with a single mismatched cert it must therefore be empty. + result.test_is_true("ca_certificates empty", parsed.ca_certificates().empty()); + return result; + } + + // A bag with an OID the parser doesn't handle (e.g. PKCS12.SecretBag) + // must be silently skipped but recorded in unknown_bag_types(). + Test::Result test_unknown_bag_types() { + Test::Result result("PKCS12 unknown_bag_types reported"); + const auto pfx = Test::read_binary_data_file("pkcs12/unknown_bag_secret.pfx"); + const Botan::PKCS12 parsed(pfx, ""); + result.test_sz_eq("One unknown bag recorded", parsed.unknown_bag_types().size(), 1); + if(!parsed.unknown_bag_types().empty()) { + result.test_str_eq("Unknown bag is SecretBag", + parsed.unknown_bag_types().front().to_formatted_string(), + Botan::OID::from_string("PKCS12.SecretBag").to_formatted_string()); + } + result.test_is_true("Cert still parsed", !parsed.certificates().empty()); + return result; + } + #if defined(BOTAN_HAS_ECDSA) + + // When PKCS12_Export_Options::with_friendly_name is set, it must + // override the bundle-level friendly_name() during export. + Test::Result test_options_friendly_name_override() { + Test::Result result("PKCS12_Export_Options friendly_name overrides bundle"); + + auto rng = Test::new_rng("PKCS12_fn_override"); + auto creds = generate_credentials(*rng); + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + bundle.set_friendly_name("Bundle-Level Name"); + + auto opts = Botan::PKCS12_Export_Options::modern("ovrpw").with_friendly_name("Options-Level Name"); + const auto pfx = bundle.export_to(opts, *rng); + + const Botan::PKCS12 parsed(pfx, "ovrpw"); + result.test_is_true("Parsed has friendly name", parsed.friendly_name().has_value()); + if(parsed.friendly_name()) { + result.test_str_eq("Options FN wins over bundle FN", *parsed.friendly_name(), "Options-Level Name"); + } + + // Also verify the opposite path: with no override in options, the + // bundle FN is what ends up in the PFX. + auto opts_no_ovr = Botan::PKCS12_Export_Options::modern("ovrpw"); + const auto pfx2 = bundle.export_to(opts_no_ovr, *rng); + const Botan::PKCS12 parsed2(pfx2, "ovrpw"); + result.test_is_true("Parsed has bundle FN", parsed2.friendly_name().has_value()); + if(parsed2.friendly_name()) { + result.test_str_eq("Bundle FN used when options unset", *parsed2.friendly_name(), "Bundle-Level Name"); + } + + return result; + } + #endif + + // add_key(nullptr) must throw Invalid_Argument. + Test::Result test_add_null_key_rejected() { + Test::Result result("PKCS12::add_key(nullptr) rejected"); + + Botan::PKCS12 bundle; + result.test_throws("add_key(nullptr) throws", + [&]() { bundle.add_key(std::shared_ptr{}); }); + + return result; + } + #if defined(BOTAN_HAS_ECDSA) + + // add_certificate does NOT deduplicate: the same certificate added twice + // appears twice in certificates() and the resulting PFX contains two CertBags. + Test::Result test_duplicate_certificate() { + Test::Result result("PKCS12 duplicate certificate not deduplicated"); + + auto rng = Test::new_rng("PKCS12_dup_cert"); + auto creds = generate_credentials(*rng); + + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + bundle.add_certificate(creds.cert); // duplicate + result.test_sz_eq("Bundle keeps both copies", bundle.certificates().size(), 2); + + const auto pfx = bundle.export_to(Botan::PKCS12_Export_Options::modern("duppw"), *rng); + const Botan::PKCS12 parsed(pfx, "duppw"); + result.test_sz_eq("Parsed bundle keeps both copies", parsed.certificates().size(), 2); + // end_entity_certificate picks the first match by SPKI; ca_certificates + // returns the duplicate. + result.test_is_true("End-entity present", parsed.end_entity_certificate().has_value()); + result.test_sz_eq("ca_certificates() returns the duplicate", parsed.ca_certificates().size(), 1); + + return result; + } + #endif + + // set_local_key_id({}) records an explicit-but-empty value: has_value() is + // true and the contained vector is empty. (Distinct from "never set".) + Test::Result test_local_key_id_empty() { + Test::Result result("PKCS12 set_local_key_id with empty vector"); + + Botan::PKCS12 bundle; + result.test_is_false("Initially unset", bundle.local_key_id().has_value()); + bundle.set_local_key_id({}); + result.test_is_true("Explicit empty has_value", bundle.local_key_id().has_value()); + if(bundle.local_key_id()) { + result.test_is_true("Contained vector is empty", bundle.local_key_id()->empty()); + } + bundle.clear_local_key_id(); + result.test_is_false("Cleared", bundle.local_key_id().has_value()); + + return result; + } + #if defined(BOTAN_HAS_ECDSA) + + // A friendly name containing characters outside the BMP (codepoints + // above U+FFFF) cannot be encoded as BMPString and must be rejected + // during export. "\xF0\x9F\x94\x91" is U+1F511 (KEY). + Test::Result test_friendly_name_non_bmp() { + Test::Result result("PKCS12 non-BMP friendly name rejected on export"); + + auto rng = Test::new_rng("PKCS12_non_bmp"); + auto creds = generate_credentials(*rng); + + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + bundle.set_friendly_name("\xF0\x9F\x94\x91"); // U+1F511 + + result.test_throws("non-BMP friendly name throws", [&]() { + (void)bundle.export_to(Botan::PKCS12_Export_Options::modern("bmppw"), *rng); + }); + + return result; + } + #endif + + // A PFX with version=2 (only 3 is accepted). + Test::Result test_pfx_invalid_version() { + Test::Result result("PKCS12 unsupported version rejected"); + const auto pfx = Test::read_binary_data_file("pkcs12/pfx_version_2.pfx"); + result.test_throws("version != 3 throws", [&]() { Botan::PKCS12(pfx, ""); }); + return result; + } + #if defined(BOTAN_HAS_ECDSA) + + // Trailing bytes after a valid PFX must be rejected (pfx_seq.verify_end()). + Test::Result test_trailing_data() { + Test::Result result("PKCS12 trailing data rejected"); + + auto rng = Test::new_rng("PKCS12_trailing"); + auto creds = generate_credentials(*rng); + + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + auto pfx = bundle.export_to(Botan::PKCS12_Export_Options::modern("trailpw"), *rng); + + pfx.push_back(0x42); + pfx.push_back(0x42); + pfx.push_back(0x42); + + result.test_throws("trailing bytes throw", [&]() { Botan::PKCS12(pfx, "trailpw"); }); + + return result; + } + #endif + + // An AuthenticatedSafe ContentInfo carrying EnvelopedData (PKCS7) must be + // rejected. EnvelopedData is in the spec but not implemented here. + Test::Result test_envelopeddata_rejected() { + Test::Result result("PKCS12 EnvelopedData content type rejected"); + const auto pfx = Test::read_binary_data_file("pkcs12/envelopeddata_content.pfx"); + result.test_throws("EnvelopedData throws", [&]() { Botan::PKCS12(pfx, ""); }); + return result; + } + + // A PFX with a CrlBag (RFC 7292 sec.4.2.4) -- not implemented here -- must be + // skipped but recorded in unknown_bag_types(). + Test::Result test_crl_bag_unknown() { + Test::Result result("PKCS12 CrlBag reported as unknown"); + const auto pfx = Test::read_binary_data_file("pkcs12/unknown_bag_crl.pfx"); + const Botan::PKCS12 parsed(pfx, ""); + result.test_sz_eq("One unknown bag reported", parsed.unknown_bag_types().size(), 1); + if(!parsed.unknown_bag_types().empty()) { + result.test_str_eq("Reported OID is CrlBag", + parsed.unknown_bag_types().front().to_formatted_string(), + Botan::OID::from_string("PKCS12.CRLBag").to_formatted_string()); + } + return result; + } + #if defined(BOTAN_HAS_ECDSA) + + // Roundtrip a PFX with a configurable MAC digest. Used to exercise the + // SHA-2 family beyond SHA-256 (the latter is covered by test_mac_sha256). + Test::Result test_mac_digest(const std::string& digest) { + Test::Result result("PKCS12 MAC digest: " + digest); + + auto rng = Test::new_rng("PKCS12_mac_" + digest); + auto creds = generate_credentials(*rng); + + const auto opts = Botan::PKCS12_Export_Options::legacy_compat("digestpw").with_mac_digest(digest); + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + const auto pfx = bundle.export_to(opts, *rng); + result.test_sz_gt("PFX data generated", pfx.size(), 0); + + const Botan::PKCS12 parsed(pfx, "digestpw"); + result.test_is_true("Has private key", !parsed.private_keys().empty()); + result.test_is_true("Has certificate", parsed.end_entity_certificate().has_value()); + + return result; + } + #endif + + #if defined(BOTAN_HAS_PKCS5_PBES2) + #if defined(BOTAN_HAS_ECDSA) + Test::Result test_mac_sha256() { + Test::Result result("PKCS12 MAC SHA-256 roundtrip"); + + auto rng = Test::new_rng("PKCS12_mac_sha256"); + auto creds = generate_credentials(*rng); + + // Modern defaults: PBES2-SHA256-AES256 key encryption, SHA-256 MAC. + const auto opts = Botan::PKCS12_Export_Options::modern("mactest").with_iterations(2048); + Botan::PKCS12 bundle; + bundle.add_key(creds.key); + bundle.add_certificate(creds.cert); + const auto pfx = bundle.export_to(opts, *rng); + result.test_sz_gt("PFX data generated", pfx.size(), 0); + + const auto parsed = Botan::PKCS12(pfx, "mactest"); + result.test_is_true("Has private key", !parsed.private_keys().empty()); + result.test_is_true("Has certificate", parsed.end_entity_certificate().has_value()); + + return result; + } + #endif + #endif +}; + +BOTAN_REGISTER_TEST("pkcs12", "pkcs12_format", PKCS12_Tests); + +} // namespace + +} // namespace Botan_Tests + +#endif