diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 63dd477..5899979 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -31,6 +31,9 @@ jobs: elixir: '1.17' otp: '27.0' lint: lint + - pair: + elixir: '1.18' + otp: '28.0' steps: - uses: actions/checkout@v2 diff --git a/README.md b/README.md index acbbd2a..49ef6ee 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,17 @@ Requires Erlang/OTP 20.1 or later. Development and public release of this package were made possible by [Bluecode](https://bluecode.com/). +## OTP 28 support + +Due to internal changes in the Erlang/OTP `public_key` application in OTP 28, +please beware that: + +* When using OTP >= 28, please use OTP 28.0.1 or later; this package is not + compatible with the initial OTP 28.0 release (more specifically: it requires + `public_key` version 1.18.1, it will not work with 1.18) +* When compiled with OTP 27 or earlier, this package will not run on OTP 28 + or later + ## Usage ### As a Certificate Authority (CA) diff --git a/lib/x509/asn1.ex b/lib/x509/asn1.ex index 84a1f07..1d600e2 100644 --- a/lib/x509/asn1.ex +++ b/lib/x509/asn1.ex @@ -2,55 +2,66 @@ defmodule X509.ASN1 do @moduledoc false require Record + alias X509.ASN1.OIDImport + alias X509.Util + + if Util.app_version(:public_key) >= [1, 18] do + @additional_records [ + tbs_cert_list_signature: :TBSCertList_signature, + certificate_list_algorithm_identifier: :CertificateList_algorithmIdentifier + ] + else + @additional_records [] + end # Records to import from :public_key's HRL files, and their snake-case names @records [ - # RSA keys - rsa_private_key: :RSAPrivateKey, - rsa_public_key: :RSAPublicKey, + # RSA keys + rsa_private_key: :RSAPrivateKey, + rsa_public_key: :RSAPublicKey, - # EC keys - ec_private_key: :ECPrivateKey, - ec_point: :ECPoint, + # EC keys + ec_private_key: :ECPrivateKey, + ec_point: :ECPoint, - # PrivateKeyInfo and SPKI - private_key_info: :PrivateKeyInfo, - private_key_info_private_key_algorithm: :PrivateKeyInfo_privateKeyAlgorithm, - otp_subject_public_key_info: :OTPSubjectPublicKeyInfo, - subject_public_key_info: :SubjectPublicKeyInfo, - public_key_algorithm: :PublicKeyAlgorithm, - algorithm_identifier: :AlgorithmIdentifier, + # PrivateKeyInfo and SPKI + private_key_info: :PrivateKeyInfo, + private_key_info_private_key_algorithm: :PrivateKeyInfo_privateKeyAlgorithm, + otp_subject_public_key_info: :OTPSubjectPublicKeyInfo, + subject_public_key_info: :SubjectPublicKeyInfo, + public_key_algorithm: :PublicKeyAlgorithm, + algorithm_identifier: :AlgorithmIdentifier, - # Names (RDNs) - attribute_type_and_value: :AttributeTypeAndValue, + # Names (RDNs) + attribute_type_and_value: :AttributeTypeAndValue, - # CSRs - certification_request: :CertificationRequest, - certification_request_info: :CertificationRequestInfo, - certification_request_subject_pk_info: :CertificationRequestInfo_subjectPKInfo, - certification_request_subject_pk_info_algorithm: - :CertificationRequestInfo_subjectPKInfo_algorithm, - certification_request_signature_algorithm: :CertificationRequest_signatureAlgorithm, - certification_request_attribute: :"AttributePKCS-10", + # CSRs + certification_request: :CertificationRequest, + certification_request_info: :CertificationRequestInfo, + certification_request_subject_pk_info: :CertificationRequestInfo_subjectPKInfo, + certification_request_subject_pk_info_algorithm: + :CertificationRequestInfo_subjectPKInfo_algorithm, + certification_request_signature_algorithm: :CertificationRequest_signatureAlgorithm, + certification_request_attribute: :"AttributePKCS-10", - # Certificates - certificate: :Certificate, - otp_certificate: :OTPCertificate, - tbs_certificate: :TBSCertificate, - otp_tbs_certificate: :OTPTBSCertificate, - signature_algorithm: :SignatureAlgorithm, - validity: :Validity, - extension: :Extension, - basic_constraints: :BasicConstraints, - authority_key_identifier: :AuthorityKeyIdentifier, - access_description: :AccessDescription, + # Certificates + certificate: :Certificate, + otp_certificate: :OTPCertificate, + tbs_certificate: :TBSCertificate, + otp_tbs_certificate: :OTPTBSCertificate, + signature_algorithm: :SignatureAlgorithm, + validity: :Validity, + extension: :Extension, + basic_constraints: :BasicConstraints, + authority_key_identifier: :AuthorityKeyIdentifier, + access_description: :AccessDescription, - # CRLs - certificate_list: :CertificateList, - tbs_cert_list: :TBSCertList, - tbs_cert_list_revoked_certificate: :TBSCertList_revokedCertificates_SEQOF - ] + # CRLs + certificate_list: :CertificateList, + tbs_cert_list: :TBSCertList, + tbs_cert_list_revoked_certificate: :TBSCertList_revokedCertificates_SEQOF + ] ++ @additional_records # The :ECPoint record is the only ASN.1 record defined in public_key.hrl; # all other records are in either OTP-PUB-KEY.hrl or PKCS-FRAME.hrl @@ -81,8 +92,12 @@ defmodule X509.ASN1 do def null, do: open_type(<<5, 0>>) # OIDs taken from :public_key's header files - @oids OIDImport.from_lib("public_key/include/OTP-PUB-KEY.hrl") ++ - OIDImport.from_lib("public_key/include/PKCS-FRAME.hrl") + if Util.app_version(:public_key) >= [1, 18] do + @oids OIDImport.from_lib("public_key/include/public_key.hrl") + else + @oids OIDImport.from_lib("public_key/include/OTP-PUB-KEY.hrl") ++ + OIDImport.from_lib("public_key/include/PKCS-FRAME.hrl") + end # OIDs defined as a macro, so they may be used in pattern matching defmacro oid(name) do diff --git a/lib/x509/certificate/extension.ex b/lib/x509/certificate/extension.ex index 0903700..0c540fe 100644 --- a/lib/x509/certificate/extension.ex +++ b/lib/x509/certificate/extension.ex @@ -432,7 +432,7 @@ defmodule X509.Certificate.Extension do @doc false # Intended for internal use only def to_der(list) when is_list(list) do - :public_key.der_encode(:OTPExtensions, Enum.map(list, &encode/1)) + :public_key.der_encode(:Extensions, Enum.map(list, &encode/1)) end def to_der(extension() = ext) do @@ -443,8 +443,8 @@ defmodule X509.Certificate.Extension do # Intended for internal use only def from_der!(der, type \\ :Extension) - def from_der!(der, :OTPExtensions) do - :public_key.der_decode(:OTPExtensions, der) + def from_der!(der, :Extensions) do + :public_key.der_decode(:Extensions, der) |> Enum.map(&decode/1) end diff --git a/lib/x509/crl.ex b/lib/x509/crl.ex index 463f652..2c291e2 100644 --- a/lib/x509/crl.ex +++ b/lib/x509/crl.ex @@ -18,6 +18,7 @@ defmodule X509.CRL do alias X509.{Certificate, SignatureAlgorithm} alias X509.CRL.{Entry, Extension} + alias X509.Util @typedoc """ `:CertificateList` record, as used in Erlang's `:public_key` module @@ -57,7 +58,7 @@ defmodule X509.CRL do @spec new([Entry.t()], Certificate.t(), X509.PrivateKey.t(), Keyword.t()) :: t() def new(revoked, issuer, issuer_key, opts \\ []) do hash = Keyword.get(opts, :hash, :sha256) - algorithm = SignatureAlgorithm.new(hash, issuer_key, :AlgorithmIdentifier) + {algorithm1, algorithm2} = signature_algorithms(hash, issuer_key) this_update = opts @@ -87,8 +88,8 @@ defmodule X509.CRL do tbs = tbs_cert_list( version: :v2, - signature: algorithm, - issuer: issuer |> Certificate.subject() |> :pubkey_cert_records.transform(:encode), + signature: algorithm1, + issuer: issuer_subject(issuer), thisUpdate: this_update, nextUpdate: next_update, revokedCertificates: revoked_certificates(revoked), @@ -99,11 +100,33 @@ defmodule X509.CRL do certificate_list( tbsCertList: tbs, - signatureAlgorithm: algorithm, + signatureAlgorithm: algorithm2, signature: :public_key.sign(tbs_der, hash, issuer_key) ) end + if Util.app_version(:public_key) >= [1, 18] do + defp signature_algorithms(hash, issuer_key) do + { + SignatureAlgorithm.new(hash, issuer_key, :TBSCertList_signature), + SignatureAlgorithm.new(hash, issuer_key, :CertificateList_algorithmIdentifier) + } + end + + defp issuer_subject(cert) do + cert |> Certificate.subject() + end + else + defp signature_algorithms(hash, issuer_key) do + algorithm = SignatureAlgorithm.new(hash, issuer_key, :AlgorithmIdentifier) + {algorithm, algorithm} + end + + defp issuer_subject(cert) do + cert |> Certificate.subject() |> :pubkey_cert_records.transform(:encode) + end + end + @doc """ Verifies whether a CRL matches the given issuer certificate and has a valid signature. diff --git a/lib/x509/csr.ex b/lib/x509/csr.ex index 3923879..7fd78e9 100644 --- a/lib/x509/csr.ex +++ b/lib/x509/csr.ex @@ -173,7 +173,7 @@ defmodule X509.CSR do case attribute do certification_request_attribute(values: [asn1_OPENTYPE: der]) -> - Extension.from_der!(der, :OTPExtensions) + Extension.from_der!(der, :Extensions) _ -> [] diff --git a/lib/x509/public_key.ex b/lib/x509/public_key.ex index 3f4156a..b84c6be 100644 --- a/lib/x509/public_key.ex +++ b/lib/x509/public_key.ex @@ -1,6 +1,8 @@ defmodule X509.PublicKey do import X509.ASN1 + alias X509.Util + @moduledoc """ Functions for deriving, reading and writing RSA and EC public keys. """ @@ -57,8 +59,7 @@ defmodule X509.PublicKey do algorithm: algorithm_identifier( algorithm: oid(:rsaEncryption), - # NULL, DER encoded - parameters: <<5, 0>> + parameters: maybe_encode_parameters(:NULL) ), subjectPublicKey: :public_key.der_encode(:RSAPublicKey, public_key) ) @@ -69,7 +70,7 @@ defmodule X509.PublicKey do algorithm: algorithm_identifier( algorithm: oid(:"id-ecPublicKey"), - parameters: :public_key.der_encode(:EcpkParameters, parameters) + parameters: maybe_encode_parameters(parameters) ), subjectPublicKey: public_key ) @@ -119,6 +120,17 @@ defmodule X509.PublicKey do ) end + if Util.app_version(:public_key) >= [1, 18] do + defp maybe_encode_parameters(:NULL), do: :NULL + defp maybe_encode_parameters(parameters), do: parameters + else + defp maybe_encode_parameters(:NULL), do: <<5, 0>> + + defp maybe_encode_parameters(parameters) do + :public_key.der_encode(:EcpkParameters, parameters) + end + end + @doc """ Extracts a public key from a SubjectPublicKeyInfo style container. @@ -130,8 +142,12 @@ defmodule X509.PublicKey do algorithm_identifier(algorithm: oid(:rsaEncryption)) -> :public_key.der_decode(:RSAPublicKey, public_key) - algorithm_identifier(algorithm: oid(:"id-ecPublicKey"), parameters: parameters) -> + algorithm_identifier(algorithm: oid(:"id-ecPublicKey"), parameters: parameters) + when is_binary(parameters) -> {ec_point(point: public_key), :public_key.der_decode(:EcpkParameters, parameters)} + + algorithm_identifier(algorithm: oid(:"id-ecPublicKey"), parameters: parameters) -> + {ec_point(point: public_key), parameters} end end diff --git a/lib/x509/rdn_sequence.ex b/lib/x509/rdn_sequence.ex index 5671437..3e17439 100644 --- a/lib/x509/rdn_sequence.ex +++ b/lib/x509/rdn_sequence.ex @@ -79,35 +79,6 @@ defmodule X509.RDNSequence do attribute types, when attribute values exceed the maximum length ('upper bound' in the RFC) or when values cannot be coerced into the expected ASN.1 type. - - ## Examples: - - iex> X509.RDNSequence.new("/C=US/CN=Bob") - {:rdnSequence, - [ - [{:AttributeTypeAndValue, {2, 5, 4, 6}, <<19, 2, 85, 83>>}], - [{:AttributeTypeAndValue, {2, 5, 4, 3}, <<12, 3, 66, 111, 98>>}] - ]} - - iex> X509.RDNSequence.new("C=CN, givenName=麗") - {:rdnSequence, - [ - [{:AttributeTypeAndValue, {2, 5, 4, 6}, <<19, 2, 67, 78>>}], - [{:AttributeTypeAndValue, {2, 5, 4, 42}, <<12, 3, 233, 186, 151>>}] - ]} - - iex> X509.RDNSequence.new(commonName: "Elixir") - {:rdnSequence, - [ - [{:AttributeTypeAndValue, {2, 5, 4, 3}, <<12, 6, 69, 108, 105, 120, 105, 114>>}] - ]} - - iex> X509.RDNSequence.new(language: "Elixir") - ** (FunctionClauseError) no function clause matching in X509.RDNSequence.new_attr/1 - - iex> X509.RDNSequence.new("C=!!") - ** (ArgumentError) unsupported character(s) in `PrintableString` attribute - """ @spec new(String.t() | attr_list(), :plain | :otp) :: t() def new(rdn, type \\ :plain) @@ -178,7 +149,7 @@ defmodule X509.RDNSequence do def get_attr({:rdnSequence, sequence}, attr_type) do oid = attr_type_to_oid(attr_type) - for {:AttributeTypeAndValue, ^oid, value} = attr <- List.flatten(sequence) do + for {_, ^oid, value} = attr <- List.flatten(sequence) do if is_binary(value) do # FIXME: avoid calls to undocumented functions in :public_key app {_, _, value} = :pubkey_cert_records.transform(attr, :decode) @@ -252,6 +223,10 @@ defmodule X509.RDNSequence do attr_oid_to_string(oid) <> "=" <> attr_value_to_string(value) end + defp attr_to_string({:SingleAttribute, oid, value}) do + attr_oid_to_string(oid) <> "=" <> attr_value_to_string(value) + end + defp attr_oid_to_string(oid(:"id-at-countryName")), do: "C" defp attr_oid_to_string(oid(:"id-at-organizationName")), do: "O" defp attr_oid_to_string(oid(:"id-at-organizationalUnitName")), do: "OU" @@ -277,6 +252,7 @@ defmodule X509.RDNSequence do |> Enum.join(".") end + defp attr_value_to_string({:correct, value}), do: List.to_string(value) defp attr_value_to_string({:utf8String, value}), do: value defp attr_value_to_string({:printableString, value}), do: List.to_string(value) defp attr_value_to_string({:ia5String, value}), do: List.to_string(value) diff --git a/lib/x509/signature_algorithm.ex b/lib/x509/signature_algorithm.ex index 0a53ebd..efe3ac0 100644 --- a/lib/x509/signature_algorithm.ex +++ b/lib/x509/signature_algorithm.ex @@ -3,6 +3,8 @@ defmodule X509.SignatureAlgorithm do import X509.ASN1 + alias X509.Util + # Returns a signature algorithm record for the given public key type and hash # algorithm; this is essentially the reverse of # `:public_key.pkix_sign_types/1` @@ -31,16 +33,40 @@ defmodule X509.SignatureAlgorithm do certification_request_signature_algorithm(algorithm: oid, parameters: parameters) end - def new(hash, signature, :AlgorithmIdentifier) do - # The AlgorithmIdentifier encoder in OTP's :public_key expects the - # parameters to be passed in as a raw binary DER, rather than an ASN.1 - # OpenType record - case algorithm(hash, signature) do - {oid, {:asn1_OPENTYPE, parameters_der}} -> - algorithm_identifier(algorithm: oid, parameters: parameters_der) + if Util.app_version(:public_key) >= [1, 18] do + def new(hash, signature, :AlgorithmIdentifier) do + case algorithm(hash, signature) do + {oid, :asn1_NOVALUE} -> + algorithm_identifier(algorithm: oid) + + {oid, parameters} -> + algorithm_identifier(algorithm: oid, parameters: parameters) + end + end + else + def new(hash, signature, :AlgorithmIdentifier) do + # The AlgorithmIdentifier encoder in OTP's :public_key expects the + # parameters to be passed in as a raw binary DER, rather than an ASN.1 + # OpenType record + case algorithm(hash, signature) do + {oid, {:asn1_OPENTYPE, parameters_der}} -> + algorithm_identifier(algorithm: oid, parameters: parameters_der) + + {oid, :asn1_NOVALUE} -> + algorithm_identifier(algorithm: oid) + end + end + end + + if Util.app_version(:public_key) >= [1, 18] do + def new(hash, signature, :TBSCertList_signature) do + {oid, parameters} = algorithm(hash, signature) + tbs_cert_list_signature(algorithm: oid, parameters: parameters) + end - {oid, :asn1_NOVALUE} -> - algorithm_identifier(algorithm: oid) + def new(hash, signature, :CertificateList_algorithmIdentifier) do + {oid, parameters} = algorithm(hash, signature) + certificate_list_algorithm_identifier(algorithm: oid, parameters: parameters) end end diff --git a/mix.exs b/mix.exs index eb1a4fc..5a160aa 100644 --- a/mix.exs +++ b/mix.exs @@ -30,7 +30,7 @@ defmodule X509.MixProject do end defp extra_applications(_env) do - [:crypto, :public_key, :logger, :ssl] + [:crypto, :public_key, :logger, :ssl, :syntax_tools] end defp deps do diff --git a/test/x509/crl_test.exs b/test/x509/crl_test.exs index b085a83..aa3d5b9 100644 --- a/test/x509/crl_test.exs +++ b/test/x509/crl_test.exs @@ -2,6 +2,8 @@ defmodule X509.CRLTest do use ExUnit.Case import X509.ASN1 + alias X509.Util + doctest X509.CRL describe "RSA" do @@ -49,7 +51,11 @@ defmodule X509.CRLTest do test :issuer, context do crl = X509.CRL.new([], context.ca, context.ca_key) - assert X509.CRL.issuer(crl) == X509.RDNSequence.new("/CN=My Root CA") + if Util.app_version(:public_key) >= [1, 18] do + assert X509.CRL.issuer(crl) == X509.RDNSequence.new("/CN=My Root CA", :otp) + else + assert X509.CRL.issuer(crl) == X509.RDNSequence.new("/CN=My Root CA") + end end test "this_update and next_update", context do diff --git a/test/x509/rdn_sequence_test.exs b/test/x509/rdn_sequence_test.exs index 13d511e..f98cb42 100644 --- a/test/x509/rdn_sequence_test.exs +++ b/test/x509/rdn_sequence_test.exs @@ -1,7 +1,68 @@ defmodule X509.RDNSequenceTest do use ExUnit.Case + + alias X509.Util + doctest X509.RDNSequence + test "simple case" do + if Util.app_version(:public_key) >= [1, 18] do + assert {:rdnSequence, + [ + [{:SingleAttribute, {2, 5, 4, 6}, {:correct, ~c"US"}}], + [{:SingleAttribute, {2, 5, 4, 3}, {:utf8String, "Bob"}}] + ]} = X509.RDNSequence.new("/C=US/CN=Bob") + else + assert {:rdnSequence, + [ + [{:AttributeTypeAndValue, {2, 5, 4, 6}, <<19, 2, 85, 83>>}], + [{:AttributeTypeAndValue, {2, 5, 4, 3}, <<12, 3, 66, 111, 98>>}] + ]} = X509.RDNSequence.new("/C=US/CN=Bob") + end + end + + test "UTF string" do + if Util.app_version(:public_key) >= [1, 18] do + assert {:rdnSequence, + [ + [{:SingleAttribute, {2, 5, 4, 6}, {:correct, ~c"CN"}}], + [{:SingleAttribute, {2, 5, 4, 42}, {:utf8String, "麗"}}] + ]} = X509.RDNSequence.new("C=CN, givenName=麗") + else + assert {:rdnSequence, + [ + [{:AttributeTypeAndValue, {2, 5, 4, 6}, <<19, 2, 67, 78>>}], + [{:AttributeTypeAndValue, {2, 5, 4, 42}, <<12, 3, 233, 186, 151>>}] + ]} = X509.RDNSequence.new("C=CN, givenName=麗") + end + end + + test "keyword list" do + if Util.app_version(:public_key) >= [1, 18] do + assert {:rdnSequence, + [ + [{:SingleAttribute, {2, 5, 4, 3}, {:utf8String, "Elixir"}}] + ]} = X509.RDNSequence.new(commonName: "Elixir") + else + assert {:rdnSequence, + [ + [{:AttributeTypeAndValue, {2, 5, 4, 3}, <<12, 6, 69, 108, 105, 120, 105, 114>>}] + ]} = X509.RDNSequence.new(commonName: "Elixir") + end + end + + test "unknown attribute" do + assert_raise FunctionClauseError, fn -> + X509.RDNSequence.new(language: "Elixir") + end + end + + test "illegal attribute value" do + assert_raise ArgumentError, fn -> + X509.RDNSequence.new("C=!!") + end + end + test "countryName" do # Allow countryName length >2, even though strictly this is a violation of # the spec; OTP's :public_key does the same