summaryrefslogtreecommitdiff
path: root/src/x509.erl
diff options
context:
space:
mode:
authorLinus Nordberg <linus@nordberg.se>2015-03-19 17:49:41 +0100
committerLinus Nordberg <linus@nordberg.se>2015-03-23 15:50:19 +0100
commitd1d2185b420d873a97bc78c5e07482accaf574fc (patch)
tree7abc6c9537c34103a9a2dea65d50df348f378a4a /src/x509.erl
parente2404caabb5ce3f7dca21cdedddbf744f47e6c3e (diff)
Add precert handling.
Diffstat (limited to 'src/x509.erl')
-rw-r--r--src/x509.erl255
1 files changed, 186 insertions, 69 deletions
diff --git a/src/x509.erl b/src/x509.erl
index 5a0e871..43b90b3 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 |
@@ -18,15 +18,49 @@
-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.
-%%%%%%%%%%%%%%%%%%%%
+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).
+
+%% @doc Return the detoxed cet in LeafDer and the issuer leaf hash.
+-spec detox(binary(), [binary()]) -> {binary(), binary()}.
+detox(LeafDer, ChainDer) ->
+ detox_precert(LeafDer, nth(1, ChainDer), nth(2, ChainDer)).
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%% Private functions.
+
%% @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 +71,12 @@ 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 ->
+-spec normalise_chain([binary()], [binary()], integer()) ->
+ {false, reason()} | {true, list()}.
+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,9 +92,9 @@ 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.
@@ -90,9 +124,10 @@ 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,
@@ -114,7 +149,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.
@@ -159,9 +194,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,54 +207,139 @@ 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.
-%% 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.
+%% 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;
+ Orig ->
+ Orig
+ end
+ end,
+ TBSCert#'TBSCertificate'.extensions),
+ TBSCert#'TBSCertificate'{issuer = ParentIssuer,
+ extensions = NewExtensions}
+ end.
--spec detox_precert([#'Certificate'{}]) -> [#'Certificate'{}].
-detox_precert(CertChain) ->
- CertChain. % NYI
+-define(CA_POISON_OID, {1,3,6,1,4,1,11129,2,4,4}).
+
+-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)
+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 = pubkey_cert:extensions_list(TBSCert#'TBSCertificate'.extensions),
+ SanitisedExtensions =
+ filter(fun(E) -> not poisoned_leaf_p(E) end, Extensions),
+ NewTBSCert = TBSCert#'TBSCertificate'{extensions = SanitisedExtensions},
+ NewTBSCert.
+
+-define(LEAF_POISON_OID, {1,3,6,1,4,1,11129,2,4,3}).
+-define(LEAF_POISON_VAL, [5,0]).
+
+poisoned_leaf_p(#'Extension'{extnID = ?LEAF_POISON_OID,
+ critical = true,
+ extnValue = ?LEAF_POISON_VAL}) ->
+ true;
+poisoned_leaf_p(_) ->
+ false.
+
+%%%% PEM files.
ders_from_pemfiles(Dir, Filenames) ->
lists:flatten(
[ders_from_pemfile(filename:join(Dir, X)) || X <- Filenames]).
@@ -297,21 +414,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 +450,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()].