diff options
Diffstat (limited to 'src/x509.erl')
-rw-r--r-- | src/x509.erl | 276 |
1 files changed, 192 insertions, 84 deletions
diff --git a/src/x509.erl b/src/x509.erl index 5a0e871..eae1468 100644 --- a/src/x509.erl +++ b/src/x509.erl @@ -3,10 +3,10 @@ -module(x509). -export([normalise_chain/2, cert_string/1, read_pemfiles_from_dir/1, - self_signed/1]). - + self_signed/1, detox/2]). -include_lib("public_key/include/public_key.hrl"). -include_lib("eunit/include/eunit.hrl"). +-import(lists, [nth/2, filter/2]). -type reason() :: {chain_too_long | root_unknown | @@ -14,19 +14,57 @@ encoding_invalid}. -define(MAX_CHAIN_LENGTH, 10). +-define(LEAF_POISON_OID, {1,3,6,1,4,1,11129,2,4,3}). +-define(LEAF_POISON_VAL, [5,0]). +-define(CA_POISON_OID, {1,3,6,1,4,1,11129,2,4,4}). -spec normalise_chain([binary()], [binary()]) -> {ok, [binary()]} | {error, reason()}. normalise_chain(AcceptableRootCerts, CertChain) -> - case valid_chain_p(AcceptableRootCerts, CertChain, ?MAX_CHAIN_LENGTH) of + case normalise_chain(AcceptableRootCerts, CertChain, ?MAX_CHAIN_LENGTH) of {false, Reason} -> {error, Reason}; {true, Root} -> - [Leaf | Chain] = CertChain, - {ok, [detox_precert(Leaf) | Chain] ++ Root} + {ok, CertChain ++ Root} end. -%%%%%%%%%%%%%%%%%%%% +-spec cert_string(binary()) -> string(). +cert_string(Der) -> + mochihex:to_hex(crypto:hash(sha, Der)). + +-spec read_pemfiles_from_dir(file:filename()) -> [binary()]. +%% @doc Reading certificates from files. Flattening the result -- all +%% certs in all files are returned in a single list. +read_pemfiles_from_dir(Dir) -> + case file:list_dir(Dir) of + {error, enoent} -> + lager:error("directory does not exist: ~p", [Dir]), + []; + {error, Reason} -> + lager:error("unable to read directory ~p: ~p", [Dir, Reason]), + []; + {ok, Filenames} -> + Files = lists:filter( + fun(F) -> string:equal(".pem", filename:extension(F)) end, + Filenames), + ders_from_pemfiles(Dir, Files) + end. + +-spec self_signed([binary()]) -> [binary()]. +%% @doc Return a list of certs in L that are self signed. +self_signed(L) -> + lists:filter(fun(Cert) -> signed_by_p(Cert, Cert) end, L). + +-spec detox(binary(), [binary()]) -> {binary(), binary()}. +%% @doc Return the detoxed cet in LeafDer and the issuer leaf hash. +detox(LeafDer, ChainDer) -> + detox_precert(LeafDer, nth(1, ChainDer), nth(2, ChainDer)). + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% Private functions. + +-spec normalise_chain([binary()], [binary()], integer()) -> + {false, reason()} | {true, list()}. %% @doc Verify that the leaf cert or precert has a valid chain back to %% an acceptable root cert. The order of certificates in the second %% argument is: leaf cert in head, chain in tail. Order of first @@ -37,12 +75,10 @@ normalise_chain(AcceptableRootCerts, CertChain) -> %% amongst the acceptable root certs. Otherwise it contains exactly %% one element, a CA cert from the acceptable root certs signing the %% root of the chain. --spec valid_chain_p([binary()], [binary()], integer()) -> - {false, reason()} | {true, list()}. -valid_chain_p(_, _, MaxChainLength) when MaxChainLength =< 0 -> +normalise_chain(_, _, MaxChainLength) when MaxChainLength =< 0 -> %% Chain too long. {false, chain_too_long}; -valid_chain_p(AcceptableRootCerts, [TopCert], MaxChainLength) -> +normalise_chain(AcceptableRootCerts, [TopCert], MaxChainLength) -> %% Check root of chain. case lists:member(TopCert, AcceptableRootCerts) of true -> @@ -58,17 +94,17 @@ valid_chain_p(AcceptableRootCerts, [TopCert], MaxChainLength) -> Root -> {true, [Root]} end end; -valid_chain_p(AcceptableRootCerts, [BottomCert|Rest], MaxChainLength) -> +normalise_chain(AcceptableRootCerts, [BottomCert|Rest], MaxChainLength) -> case signed_by_p(BottomCert, hd(Rest)) of - true -> valid_chain_p(AcceptableRootCerts, Rest, MaxChainLength - 1); + true -> normalise_chain(AcceptableRootCerts, Rest, MaxChainLength - 1); false -> {false, signature_mismatch} end. +-spec signer(binary(), [binary()]) -> notfound | binary(). %% @doc Return first cert in list signing Cert, or notfound. NOTE: %% This is potentially expensive. It'd be more efficient to search for %% Cert.issuer in a list of Issuer.subject's. If so, maybe make the %% matching somewhat fuzzy unless that too is expensive. --spec signer(binary(), [binary()]) -> notfound | binary(). signer(_Cert, []) -> notfound; signer(Cert, [H|T]) -> @@ -82,6 +118,7 @@ signer(Cert, [H|T]) -> signer(Cert, T) end. +-spec encoded_tbs_cert(binary()) -> binary(). %% Code from pubkey_cert:encoded_tbs_cert/1. encoded_tbs_cert(DerCert) -> {ok, PKIXCert} = @@ -90,14 +127,14 @@ encoded_tbs_cert(DerCert) -> PKIXCert, EncodedTBSCert. +-spec extract_verify_data(#'Certificate'{}, binary()) -> {ok, tuple()} | error. +%% @doc Return DER encoded TBScertificate, digest type and signature. %% Code from pubkey_cert:extract_verify_data/2. --spec verifydata_from_cert(#'Certificate'{}, binary()) -> {ok, tuple()} | error. -verifydata_from_cert(Cert, DerCert) -> +extract_verify_data(Cert, DerCert) -> PlainText = encoded_tbs_cert(DerCert), {_, Sig} = Cert#'Certificate'.signature, SigAlgRecord = Cert#'Certificate'.signatureAlgorithm, SigAlg = SigAlgRecord#'AlgorithmIdentifier'.algorithm, - lager:debug("SigAlg: ~p", [SigAlg]), try {DigestType, _} = public_key:pkix_sign_types(SigAlg), {ok, {PlainText, DigestType, Sig}} @@ -114,7 +151,7 @@ verify_sig(Cert, DerCert, % Certificate to verify. tbsCertificate = #'TBSCertificate'{ subjectPublicKeyInfo = IssuerSPKI}}) -> %% Dig out digest, digest type and signature from Cert/DerCert. - case verifydata_from_cert(Cert, DerCert) of + case extract_verify_data(Cert, DerCert) of error -> false; {ok, Tuple} -> verify_sig2(IssuerSPKI, Tuple) end. @@ -125,10 +162,6 @@ verify_sig2(IssuerSPKI, {DigestOrPlainText, DigestType, Signature}) -> algorithm = #'AlgorithmIdentifier'{algorithm = Alg, parameters = Params}, subjectPublicKey = {0, Key0}} = IssuerSPKI, KeyType = pubkey_cert_records:supportedPublicKeyAlgorithms(Alg), - lager:debug("Alg: ~p", [Alg]), - lager:debug("Params: ~p", [Params]), - lager:debug("KeyType: ~p", [KeyType]), - lager:debug("Key0: ~p", [Key0]), IssuerKey = case KeyType of 'RSAPublicKey' -> @@ -141,12 +174,6 @@ verify_sig2(IssuerSPKI, {DigestOrPlainText, DigestType, Signature}) -> lager:error("NIY: Issuer key type ~p", [KeyType]), false end, - - lager:debug("DigestOrPlainText: ~p", [DigestOrPlainText]), - lager:debug("DigestType: ~p", [DigestType]), - lager:debug("Signature: ~p", [Signature]), - lager:debug("IssuerKey: ~p", [IssuerKey]), - %% Verify the signature. public_key:verify(DigestOrPlainText, DigestType, Signature, IssuerKey). @@ -159,9 +186,6 @@ signed_by_p(DerCert, IssuerDerCert) when is_binary(DerCert), DerCert, public_key:pkix_decode_cert(IssuerDerCert, plain)). -cert_string(Der) -> - mochihex:to_hex(crypto:hash(sha, Der)). - parsable_cert_p(Der) -> case (catch public_key:pkix_decode_cert(Der, plain)) of #'Certificate'{} -> @@ -175,58 +199,141 @@ parsable_cert_p(Der) -> false end. --spec self_signed([binary()]) -> [binary()]. -self_signed(L) -> - lists:filter(fun(Cert) -> signed_by_p(Cert, Cert) end, L). - -%%%%%%%%%%%%%%%%%%%% -%% Precertificates according to draft-ietf-trans-rfc6962-bis-04. - +%% Precerts according to RFC6962. +%% %% Submitted precerts have a special critical poison extension -- OID %% 1.3.6.1.4.1.11129.2.4.3, whose extnValue OCTET STRING contains %% ASN.1 NULL data (0x05 0x00). - +%% %% They are signed with either the CA cert that will sign the final -%% cert or Precertificate Signing Certificate directly signed by the +%% cert or a Precertificate Signing Certificate directly signed by the %% CA cert that will sign the final cert. A Precertificate Signing %% Certificate has CA:true and Extended Key Usage: Certificate %% Transparency, OID 1.3.6.1.4.1.11129.2.4.4. +%% +%% PreCert in SignedCertificateTimestamp does _not_ contain the poison +%% extension, nor does it have an issuer which is a Precertificate +%% Signing Certificate. This means that we have to 1) remove the +%% poison extension and 2) potentially change issuer and Authority Key +%% Identifier. See RFC6962 Section 3.2. +%% +%% Changes in draft-ietf-trans-rfc6962-bis-??: TODO. + +-spec detox_precert(binary(), binary(), binary()) -> {binary(), binary()}. +%% @doc Return {DetoxedLeaf, IssuerPubKeyHash} where i) DetoxedLeaf is +%% the tbsCertificate w/o poison and adjusted issuer and authkeyid; +%% and ii) IssuerPubKeyHash is the hash over issuing cert's public +%% key. +detox_precert(LeafDer, ParentDer, GrandParentDer) -> + Leaf = public_key:pkix_decode_cert(LeafDer, plain), + Parent = public_key:pkix_decode_cert(ParentDer, plain), + GrandParent = public_key:pkix_decode_cert(GrandParentDer, plain), + DetoxedLeafTBS = remove_poison_ext(Leaf), + + %% If parent is a precert signing cert, change issuer and + %% potential authority key id to refer to grandparent. + {C, IssuerKeyHash} = + case is_precert_signer(Parent) of + true -> + {set_issuer_and_authkeyid(DetoxedLeafTBS, Parent), + extract_pub_key(GrandParent)}; + false -> + {DetoxedLeafTBS, extract_pub_key(Parent)} + end, + {public_key:pkix_encode('TBSCertificate', C, plain), + crypto:hash(sha256, public_key:pkix_encode( + 'SubjectPublicKeyInfo', IssuerKeyHash, plain))}. + +-spec extract_pub_key(#'Certificate'{}) -> #'SubjectPublicKeyInfo'{}. +extract_pub_key(#'Certificate'{ + tbsCertificate = #'TBSCertificate'{ + subjectPublicKeyInfo = SPKI}}) -> + SPKI. + +-spec set_issuer_and_authkeyid(#'TBSCertificate'{}, #'Certificate'{}) -> + #'TBSCertificate'{}. +%% @doc Return Cert with issuer and AuthorityKeyIdentifier from Parent. +set_issuer_and_authkeyid(TBSCert, + #'Certificate'{ + tbsCertificate = + #'TBSCertificate'{ + issuer = ParentIssuer, + extensions = ParentExtensions}}) -> + case pubkey_cert:select_extension(?'id-ce-authorityKeyIdentifier', + ParentExtensions) of + undefined -> + lager:debug("setting issuer only", []), + TBSCert#'TBSCertificate'{issuer = ParentIssuer}; + ParentAuthKeyExt -> + NewExtensions = + lists:map( + fun(E) -> + case E of + #'Extension'{extnID = + ?'id-ce-authorityKeyIdentifier'} -> + lager:debug("swapping auth key id to ~p", + [ParentAuthKeyExt]), + ParentAuthKeyExt; + _ -> E + end + end, + TBSCert#'TBSCertificate'.extensions), + lager:debug("setting issuer and auth key id", []), + TBSCert#'TBSCertificate'{issuer = ParentIssuer, + extensions = NewExtensions} + end. -%% A PreCert in a SignedCertificateTimestamp does _not_ contain the -%% poison extension, nor a Precertificate Signing Certificate. This -%% means that we might have to 1) remove poison extensions in leaf -%% certs, 2) remove "poisoned signatures", 3) change issuer and -%% Authority Key Identifier of leaf certs. - --spec detox_precert([#'Certificate'{}]) -> [#'Certificate'{}]. -detox_precert(CertChain) -> - CertChain. % NYI +-spec is_precert_signer(#'Certificate'{}) -> boolean(). +is_precert_signer(#'Certificate'{tbsCertificate = TBSCert}) -> + Extensions = pubkey_cert:extensions_list(TBSCert#'TBSCertificate'.extensions), + %% NOTE: It's OK to look at only the first extension found since + %% "A certificate MUST NOT include more than one instance of a + %% particular extension." --RFC5280 Sect 4.2 + case pubkey_cert:select_extension(?'id-ce-extKeyUsage', Extensions) of + #'Extension'{extnValue = Val} -> + case 'OTP-PUB-KEY':decode('ExtKeyUsageSyntax', Val) of + %% NOTE: We require that the poisoned OID is the + %% _only_ extkeyusage present. RFC6962 Sect 3.1 is not + %% really clear. + {ok, [?CA_POISON_OID]} -> is_ca(TBSCert); + _ -> false + end; + _ -> false + end. -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% --spec read_pemfiles_from_dir(file:filename()) -> [binary()]. -%% @doc Reading certificates from files. Flattening the result -- all -%% certs in all files are returned in a single list. -read_pemfiles_from_dir(Dir) -> - case file:list_dir(Dir) of - {error, enoent} -> - lager:error("directory does not exist: ~p", [Dir]), - []; - {error, Reason} -> - lager:error("unable to read directory ~p: ~p", [Dir, Reason]), - []; - {ok, Filenames} -> - Files = lists:filter( - fun(F) -> - string:equal(".pem", filename:extension(F)) - end, - Filenames), - ders_from_pemfiles(Dir, Files) +-spec is_ca(#'TBSCertificate'{}) -> binary(). +is_ca(#'TBSCertificate'{extensions = Extensions}) -> + case pubkey_cert:select_extension(?'id-ce-basicConstraints', Extensions) of + #'Extension'{critical = true, extnValue = Val} -> + case 'OTP-PUB-KEY':decode('BasicConstraints', Val) of + {ok, {'BasicConstraints', true, _}} -> true; + _ -> false + end; + _ -> false end. +-spec remove_poison_ext(#'Certificate'{}) -> #'TBSCertificate'{}. +remove_poison_ext(#'Certificate'{tbsCertificate = TBSCert}) -> + Extensions = + filter(fun(E) -> not poisoned_leaf_p(E) end, + pubkey_cert:extensions_list(TBSCert#'TBSCertificate'.extensions)), + TBSCert#'TBSCertificate'{extensions = Extensions}. + +-spec poisoned_leaf_p(binary()) -> boolean(). +poisoned_leaf_p(#'Extension'{extnID = ?LEAF_POISON_OID, + critical = true, + extnValue = ?LEAF_POISON_VAL}) -> + true; +poisoned_leaf_p(_) -> + false. + +%%%% PEM files. +-spec ders_from_pemfiles(string(), [string()]) -> [binary()]. ders_from_pemfiles(Dir, Filenames) -> lists:flatten( [ders_from_pemfile(filename:join(Dir, X)) || X <- Filenames]). +-spec ders_from_pemfile(string()) -> [binary()]. ders_from_pemfile(Filename) -> lager:debug("reading PEM from ~s", [Filename]), PemBins = pems_from_file(Filename), @@ -238,6 +345,7 @@ ders_from_pemfile(Filename) -> end, [der_from_pem(X) || X <- Pems]. +-spec der_from_pem(binary()) -> binary(). der_from_pem(Pem) -> case Pem of {_Type, Der, not_encrypted} -> @@ -297,21 +405,21 @@ valid_cert_test_() -> %% 'OTP-PUB-KEY':Func('OTP-X520countryname', Value0) %% FIXME: This error doesn't make much sense -- is my %% environment borked? - ?_assertMatch({true, _}, valid_chain_p(lists:nth(1, Chains), - lists:nth(1, Chains), 10)), + ?_assertMatch({true, _}, normalise_chain(lists:nth(1, Chains), + lists:nth(1, Chains), 10)), %% Self-signed so fail. ?_assertMatch({false, root_unknown}, - valid_chain_p(KnownRoots, - lists:nth(2, Chains), 10)), + normalise_chain(KnownRoots, + lists:nth(2, Chains), 10)), %% Leaf signed by known CA, pass. - ?_assertMatch({true, _}, valid_chain_p(KnownRoots, - lists:nth(3, Chains), 10)), + ?_assertMatch({true, _}, normalise_chain(KnownRoots, + lists:nth(3, Chains), 10)), %% Proper 3-depth chain with root in KnownRoots, pass. %% Bug CATLFISH-19 --> [info] rejecting "3ee62cb678014c14d22ebf96f44cc899adea72f1": chain_broken %% leaf sha1: 3ee62cb678014c14d22ebf96f44cc899adea72f1 %% leaf Subject: C=KR, O=Government of Korea, OU=Group of Server, OU=\xEA\xB5\x90\xEC\x9C\xA1\xEA\xB3\xBC\xED\x95\x99\xEA\xB8\xB0\xEC\x88\xA0\xEB\xB6\x80, CN=www.berea.ac.kr, CN=haksa.bits.ac.kr - ?_assertMatch({true, _}, valid_chain_p(KnownRoots, - lists:nth(4, Chains), 3)), + ?_assertMatch({true, _}, normalise_chain(KnownRoots, + lists:nth(4, Chains), 3)), %% Verify against self, pass. %% Bug CATLFISH-??, can't handle issuer keytype ECPoint. %% Issuer sha1: 6969562e4080f424a1e7199f14baf3ee58ab6abb @@ -333,21 +441,21 @@ chain_test_() -> chain_test(C0, C1) -> [ %% Root not in chain but in trust store. - ?_assertEqual({true, [C1]}, valid_chain_p([C1], [C0], 10)), - ?_assertEqual({true, [C1]}, valid_chain_p([C1], [C0], 2)), + ?_assertEqual({true, [C1]}, normalise_chain([C1], [C0], 10)), + ?_assertEqual({true, [C1]}, normalise_chain([C1], [C0], 2)), %% Chain too long. - ?_assertMatch({false, chain_too_long}, valid_chain_p([C1], [C0], 1)), + ?_assertMatch({false, chain_too_long}, normalise_chain([C1], [C0], 1)), %% Root in chain and in trust store. - ?_assertEqual({true, []}, valid_chain_p([C1], [C0, C1], 2)), + ?_assertEqual({true, []}, normalise_chain([C1], [C0, C1], 2)), %% Chain too long. - ?_assertMatch({false, chain_too_long}, valid_chain_p([C1], [C0, C1], 1)), + ?_assertMatch({false, chain_too_long}, normalise_chain([C1], [C0, C1], 1)), %% Root not in trust store. - ?_assertMatch({false, root_unknown}, valid_chain_p([], [C0, C1], 10)), + ?_assertMatch({false, root_unknown}, normalise_chain([], [C0, C1], 10)), %% Selfsigned. Actually OK. - ?_assertMatch({true, []}, valid_chain_p([C0], [C0], 10)), - ?_assertMatch({true, []}, valid_chain_p([C0], [C0], 1)), + ?_assertMatch({true, []}, normalise_chain([C0], [C0], 10)), + ?_assertMatch({true, []}, normalise_chain([C0], [C0], 1)), %% Max chain length 0 is not OK. - ?_assertMatch({false, chain_too_long}, valid_chain_p([C0], [C0], 0)) + ?_assertMatch({false, chain_too_long}, normalise_chain([C0], [C0], 0)) ]. %%-spec read_certs(file:filename()) -> [string:string()]. |