%%% Copyright (c) 2014, NORDUnet A/S. %%% See LICENSE for licensing information. -module(x509). -export([normalise_chain/2, cert_string/1, valid_cert_p/1]). -include_lib("public_key/include/public_key.hrl"). -type reason() :: {chain_too_long | root_unknown | chain_broken | signature_mismatch | encoding_invalid}. -define(MAX_CHAIN_LENGTH, 10). -spec normalise_chain([binary()], [binary()]) -> {ok, [binary()]} | {error, reason()}. normalise_chain(AcceptableRootCerts, CertChain) -> case valid_chain_p(AcceptableRootCerts, CertChain, ?MAX_CHAIN_LENGTH) of {false, Reason} -> {error, Reason}; {true, Root} -> [Leaf | Chain] = CertChain, {ok, [detox_precert(Leaf) | Chain] ++ Root} end. %%%%%%%%%%%%%%%%%%%% %% @doc Verify that the leaf cert or precert has a valid chain back to %% an acceptable root cert. Order of certificates in second argument %% is: leaf cert in head, chain in tail. Order of first argument is %% irrelevant. -spec valid_chain_p([binary()], [binary()], integer()) -> {false, reason()} | {true, list()}. valid_chain_p(_, _, MaxChainLength) when MaxChainLength =< 0 -> %% Chain too long. {false, chain_too_long}; valid_chain_p(AcceptableRootCerts, [TopCert], MaxChainLength) -> %% Check root of chain. case lists:member(TopCert, AcceptableRootCerts) of true -> %% Top cert is part of chain. {true, []}; false when MaxChainLength =< 1 -> %% Chain too long. {false, chain_too_long}; false -> %% Top cert _might_ be signed by a cert in truststore. case signer(TopCert, AcceptableRootCerts) of notfound -> {false, root_unknown}; Root -> {true, [Root]} end end; valid_chain_p(AcceptableRootCerts, [BottomCert|Rest], MaxChainLength) -> case signed_by_p(BottomCert, hd(Rest)) of true -> valid_chain_p(AcceptableRootCerts, Rest, MaxChainLength - 1); Err -> Err end. %% @doc Return first cert in list signing Cert, or notfound. -spec signer(binary(), [binary()]) -> notfound | binary(). signer(_Cert, []) -> notfound; signer(Cert, [H|T]) -> case signed_by_p(Cert, H) of true -> H; {false, _} -> signer(Cert, T) end. -spec signed_by_p(binary(), binary()) -> true | {false, reason()}. signed_by_p(Cert, IssuerCert) -> %% FIXME: Validate presence and contents (against constraints) of %% names (subject, subjectAltName, emailAddress) too? case (catch public_key:pkix_is_issuer(Cert, IssuerCert)) of {'EXIT', Reason} -> lager:info("invalid certificate: ~p: ~p", [mochihex:to_hex(crypto:hash(sha, Cert)), Reason]), {false, encoding_invalid}; true -> %% Cert.issuer does match IssuerCert.subject. Now verify %% the signature. case public_key:pkix_verify(Cert, public_key(IssuerCert)) of true -> true; false -> {false, signature_mismatch} end; false -> {false, chain_broken} end. -spec public_key(binary() | #'OTPCertificate'{}) -> public_key:public_key(). public_key(CertDer) when is_binary(CertDer) -> public_key(public_key:pkix_decode_cert(CertDer, otp)); public_key(#'OTPCertificate'{ tbsCertificate = #'OTPTBSCertificate'{subjectPublicKeyInfo = #'OTPSubjectPublicKeyInfo'{ subjectPublicKey = Key}}}) -> Key. cert_string(Der) -> lists:flatten([io_lib:format("~2.16.0B", [X]) || X <- binary_to_list(crypto:hash(sha, Der))]). valid_cert_p(Der) -> %% Use the customized ASN.1 specification "OTP-PKIX.asn1" since %% that's what's required for public_key functions we're using %% (pkix_verify, public_key:pkix_is_issuer). case (catch public_key:pkix_decode_cert(Der, otp)) of #'OTPCertificate'{} -> true; {'EXIT', Reason} -> lager:info("invalid certificate: ~p: ~p", [mochihex:to_hex(crypto:hash(sha, Der)), Reason]), false; Unknown -> lager:info("unknown error decoding cert: ~p: ~p", [mochihex:to_hex(crypto:hash(sha, Der)), Unknown]), false end. %%%%%%%%%%%%%%%%%%%% %% Precertificates according to draft-ietf-trans-rfc6962-bis-04. %% 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 %% 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. %% 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 %%%%%%%%%%%%%%%%%%%% %% Testing private functions. -include_lib("eunit/include/eunit.hrl"). -include("x509_test.hrl"). valid_cert_test_() -> C0 = ?C0, C1 = ?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)), %% Chain too long. ?_assertMatch({false, chain_too_long}, valid_chain_p([C1], [C0], 1)), %% Root in chain and in trust store. ?_assertEqual({true, []}, valid_chain_p([C1], [C0, C1], 2)), %% Chain too long. ?_assertMatch({false, chain_too_long}, valid_chain_p([C1], [C0, C1], 1)), %% Root not in trust store. ?_assertMatch({false, root_unknown}, valid_chain_p([], [C0, C1], 10)), %% Invalid signer. ?_assertMatch({false, chain_broken}, valid_chain_p([C0], [C1, C0], 10)), %% Selfsigned. Actually OK. ?_assertMatch({true, []}, valid_chain_p([C0], [C0], 10)), ?_assertMatch({true, []}, valid_chain_p([C0], [C0], 1)), %% Max chain length 0 is not OK. ?_assertMatch({false, chain_too_long}, valid_chain_p([C0], [C0], 0)) ].