PKCS12 (pkcs12 v0.1.1)

Copy Markdown View Source

Reads and writes PKCS#12 files (.pfx, .p12): the private key, its certificate and the rest of the chain.

{:ok, p12} = PKCS12.decode(File.read!("me.pfx"), "password")

p12.private_key      # PrivateKeyInfo (PKCS#8), in DER
p12.certificate      # the certificate of that key, in DER
p12.ca_certificates  # the other certificates, in DER, in file order

{:ok, pfx} = PKCS12.encode(p12, "new password")

Everything is done in Elixir on top of :crypto and :public_key, with no NIF of its own and no call to the openssl binary. RC2, which the legacy files use for their certificates, is implemented by the library, because OpenSSL 3 only has it in its "legacy" provider, often missing.

Using the result

The key and the certificates are DER binaries, the form that the rest of OTP takes:

# TLS client authentication (mTLS)
:ssl.connect(host, 443,
  certs_keys: [%{cert: [p12.certificate | p12.ca_certificates],
                 key: {:PrivateKeyInfo, p12.private_key}}],
  verify: :verify_peer,
  cacerts: :public_key.cacerts_get()
)

# Signing
key = :public_key.der_decode(:PrivateKeyInfo, p12.private_key)
signature = :public_key.sign(message, :sha256, key)

# PEM files, for tools that want them
pem = :public_key.pem_encode([{:PrivateKeyInfo, p12.private_key, :not_encrypted}])

Writing

encode/3 takes the same fields, so PEM files become a .pfx for the systems and tools that import one:

[key] = :public_key.pem_decode(File.read!("key.pem"))
[cert | chain] = for {:Certificate, der, _} <- :public_key.pem_decode(File.read!("chain.pem")), do: der

{:ok, pfx} =
  PKCS12.encode(
    %{private_key: :public_key.pem_entry_decode(key), certificate: cert, ca_certificates: chain},
    "password",
    friendly_name: "My key"
  )

A file in an old format can be read and written again in the modern one:

pfx |> PKCS12.decode!(password) |> PKCS12.encode!(password)

Algorithms

Reading, encryption:

  • PBES2 (PBKDF2 with HMAC-SHA-1/224/256/384/512) with AES-128/192/256-CBC, 3DES-CBC or DES-CBC, the default of OpenSSL 3, Java and other recent tools;
  • pbeWithSHAAnd3-KeyTripleDES-CBC and pbeWithSHAAnd40BitRC2-CBC, the "legacy" combination of OpenSSL 1.x and of many older tools, still common;
  • the other PKCS#12 ciphers: 2-key 3DES, RC2-128, and RC4-40/128 when :crypto has RC4.

Reading, integrity: the classic MAC with SHA-1/224/256/384/512, and PBMAC1 (RFC 9879). Files without a MAC are accepted; :info tells.

Writing: PBES2 with AES and PBKDF2-HMAC-SHA-256 by default, the legacy combination when asked, and any of the MACs above. See encode/3.

Not supported: files signed with, or encrypted to, a public key instead of a password (both are very rare).

Passwords

The password is a UTF-8 binary, or nil, which is the same as "". Some generators encode passwords in other ways: with each byte as a character (non-ASCII passwords in older OpenSSL versions and LibreSSL), or the empty password as no bytes at all. When reading, the library tries those forms too, and the MAC decides which is right; :info tells which one matched. When writing, it uses the standard forms only.

Summary

Types

A cipher that encode/3 writes.

A hash algorithm, named as in :crypto.

What the file uses.

What encode/3 writes: a t/0, or a map with any of its first three keys. See encode/3.

t()

A decoded PKCS#12 file.

Functions

Decodes a PKCS#12 file.

Like decode/3, but returns the result or raises PKCS12.Error.

Encodes a PKCS#12 file, in DER.

Like encode/3, but returns the file or raises PKCS12.Error.

Types

cipher()

@type cipher() ::
  :aes_256_cbc | :aes_192_cbc | :aes_128_cbc | :des_ede3_cbc | :rc2_40_cbc

A cipher that encode/3 writes.

decode_option()

@type decode_option() ::
  {:max_iterations, pos_integer()}
  | {:max_total_iterations, pos_integer()}
  | {:verify_mac, boolean()}

encode_option()

@type encode_option() ::
  {:profile, :modern | :legacy}
  | {:key_encryption, cipher() | :none}
  | {:certificate_encryption, cipher() | :none}
  | {:mac, hash() | :pbmac1 | :none}
  | {:iterations, pos_integer()}
  | {:friendly_name, String.t()}
  | {:local_key_id, binary()}

hash()

@type hash() :: :sha | :sha224 | :sha256 | :sha384 | :sha512

A hash algorithm, named as in :crypto.

info()

@type info() :: %{
  mac:
    nil
    | %{
        scheme: :hmac | :pbmac1,
        digest: hash(),
        prf: hash() | nil,
        iterations: pos_integer(),
        verified: boolean()
      },
  encryption: [
    %{
      scheme: :pbes2 | :pkcs12_pbe,
      cipher: atom(),
      digest: hash(),
      iterations: pos_integer()
    }
  ],
  password_encoding: :unicode | :latin1 | :null | nil
}

What the file uses.

  • :mac - nil if the file has no MAC, otherwise a map with :scheme (:hmac, the classic MAC, or :pbmac1), :digest (the HMAC hash), :prf (the PBKDF2 hash of PBMAC1, nil for :hmac), :iterations and :verified (false with verify_mac: false).
  • :encryption - one map per distinct algorithm, with :scheme (:pbes2 or :pkcs12_pbe), :cipher (such as :aes_256_cbc, :des_ede3_cbc or :rc2_40_cbc), :digest (the hash of the key derivation) and :iterations: first those of the encrypted parts of the file, then those of the encrypted keys. Empty if nothing is encrypted.
  • :password_encoding - which form of the password matched: :unicode (the standard one), :latin1 or :null. nil if the password was not needed.

More keys may be added in later versions.

input()

@type input() ::
  t()
  | %{
      optional(:private_key) => binary() | tuple() | nil,
      optional(:certificate) => binary() | nil,
      optional(:ca_certificates) => [binary()] | nil
    }

What encode/3 writes: a t/0, or a map with any of its first three keys. See encode/3.

t()

@type t() :: %PKCS12{
  bags: [PKCS12.Bag.t()],
  ca_certificates: [binary()],
  certificate: binary() | nil,
  info: info(),
  private_key: binary() | nil
}

A decoded PKCS#12 file.

  • :private_key - the first private key of the file, as a PrivateKeyInfo (PKCS#8) in DER, or nil if the file has none. inspect/1 leaves it out.
  • :certificate - the certificate of that key, in DER: the one with the same localKeyId, or else the one with the same public key. nil if there is no key, or no certificate matches.
  • :ca_certificates - every other certificate, in DER, in file order. In files with a key, that is usually the chain; the library does not reorder or validate it.
  • :bags - every bag of the file, as PKCS12.Bag.t/0, in file order, with their attributes. Files with more than one key list them all here.
  • :info - what the file uses, to warn about or refuse weak algorithms (see info/0).

Functions

decode(pfx, password, opts \\ [])

@spec decode(binary(), String.t() | nil, [decode_option()]) ::
  {:ok, t()} | {:error, PKCS12.Error.t()}

Decodes a PKCS#12 file.

password is a binary (UTF-8 text, as typed by the user) or nil, which is the same as "". A password read with System.get_env/1 is nil when the variable is not set, and a file with a password then fails with :invalid_password; System.fetch_env!/1 raises first, with the name of the variable.

Options

  • :max_iterations - the largest iteration count accepted for any one key derivation. Default: 1000000. Common tools write from 2,048 to 100,000 (the MAC of the Java legacy format); a much larger count in a file from an untrusted source is a way to make the server spend CPU.
  • :max_total_iterations - the limit for the work of all derivations together: the MAC checks (one per form of the password tried) and every decryption. Each counts its iteration count times the output blocks of the hash it needs, so a legacy 3DES decryption of 2,048 iterations counts 6,144: two blocks for the key and one for the IV. Default: 4 times :max_iterations.
  • :verify_mac - whether to check the MAC. Default: true. With false, a file with a damaged MAC can still be read, but nothing tells whether it was changed; use it only to recover a file you trust.

The work is CPU-bound: a few milliseconds for common files, up to about a second at the default limits, paid again on every call. Decode a file once, for example at startup, and keep the result. The work runs in the calling process, which the BEAM preempts as usual. To read files from untrusted sources, bound their size, lower the limits and consider a timeout around the call.

Errors

Returns {:error, %PKCS12.Error{}}; see PKCS12.Error for the reasons. A wrong password gives :invalid_password.

decode!(pfx, password, opts \\ [])

@spec decode!(binary(), String.t() | nil, [decode_option()]) :: t()

Like decode/3, but returns the result or raises PKCS12.Error.

encode(p12, password, opts \\ [])

@spec encode(input(), String.t() | nil, [encode_option()]) ::
  {:ok, binary()} | {:error, PKCS12.Error.t()}

Encodes a PKCS#12 file, in DER.

p12 is a t/0, such as one that decode/3 returns, or a map with some of the same keys:

  • :private_key - a PrivateKeyInfo (PKCS#8) in DER, or a key record of :public_key, such as {:RSAPrivateKey, ...} or {:ECPrivateKey, ...}.
  • :certificate - the certificate of that key, in DER.
  • :ca_certificates - other certificates, in DER, usually the rest of the chain. They are written in this order, after :certificate.

Any of them can be missing: a file of certificates alone, for example, is a trust store. Other keys, such as :bags and :info, are ignored, so only the first key of a decoded file and its certificates are written again. The library checks the shape of the key and of the certificates, not that the certificate belongs to the key.

password is a UTF-8 binary, or nil, which is the same as "".

The salts and IVs come from :crypto.strong_rand_bytes/1, so no two results are the same.

Options

  • :profile - the values of the options below when they are not given:
    • :modern (default) - AES-256-CBC with PBKDF2-HMAC-SHA-256 for the key and for the certificates, and a SHA-256 MAC, all with 100,000 iterations. It is the format that OpenSSL 3 and Java write by default, with a higher count than either.
    • :legacy - pbeWithSHAAnd3-KeyTripleDES-CBC for the key, pbeWithSHAAnd40BitRC2-CBC for the certificates and a SHA-1 MAC, all with 2,048 iterations: the format of OpenSSL 1.x, for importers that know nothing newer. It is weak; see the security notes.
  • :key_encryption - :aes_256_cbc, :aes_192_cbc or :aes_128_cbc (PBES2 with PBKDF2-HMAC-SHA-256), :des_ede3_cbc (pbeWithSHAAnd3-KeyTripleDES-CBC), or :none.
  • :certificate_encryption - the same choices, and :rc2_40_cbc (pbeWithSHAAnd40BitRC2-CBC). With :none, anyone can read the certificates, as they could anyway from the certificates themselves.
  • :mac - :sha256, :sha384, :sha512, :sha224 or :sha (the classic MAC with that hash), :pbmac1 (RFC 9879, with PBKDF2-HMAC-SHA-256 and HMAC-SHA-256, which OpenSSL reads from 3.4 on), or :none.
  • :iterations - the iteration count of every key derivation. A password is as hard to guess as the cheapest derivation that checks it, so they all take the same. The cost grows with the count when writing and when reading; decode/3 reads up to 1,000,000 by default, and other readers have limits of their own.
  • :friendly_name - a name for the key and its certificate, which importers show; Java uses it as the alias.
  • :local_key_id - the localKeyId of the key and its certificate, which readers use to pair them. Default: the SHA-1 hash of the certificate, when there are both.

An option given as nil takes the value of the profile.

Errors

Returns {:error, %PKCS12.Error{}} with {:invalid_input, field} when a field of p12 is not what it should be, or {:not_available, cipher} when :crypto refuses a cipher, as a FIPS provider may do with :des_ede3_cbc. Raises ArgumentError for invalid options, or for a password that is not UTF-8.

encode!(p12, password, opts \\ [])

@spec encode!(input(), String.t() | nil, [encode_option()]) :: binary()

Like encode/3, but returns the file or raises PKCS12.Error.