diff options
author | Magnus Ahltorp <map@kth.se> | 2015-03-31 19:18:30 +0200 |
---|---|---|
committer | Magnus Ahltorp <map@kth.se> | 2015-03-31 19:18:30 +0200 |
commit | ab924f51f254d1bdd6f752f8c19c4cbcc55cf0e4 (patch) | |
tree | 91261dcf3047c735207d706862bd9136f003230a /src | |
parent | a706e79fa722f681320fe1b05824352b6b9a63fc (diff) | |
parent | 13c3789add4f1630c4bc8dfccb229ebc7d4bfa38 (diff) |
Merge branch 'genauthkeys'
Diffstat (limited to 'src')
-rw-r--r-- | src/catlfish.erl | 259 | ||||
-rw-r--r-- | src/catlfish_sup.erl | 34 | ||||
-rw-r--r-- | src/catlfish_web.erl | 6 | ||||
-rw-r--r-- | src/v1.erl | 77 | ||||
-rw-r--r-- | src/x509.erl | 430 |
5 files changed, 551 insertions, 255 deletions
diff --git a/src/catlfish.erl b/src/catlfish.erl index 3956eec..ed75495 100644 --- a/src/catlfish.erl +++ b/src/catlfish.erl @@ -2,9 +2,10 @@ %%% See LICENSE for licensing information. -module(catlfish). --export([add_chain/2, entries/2, entry_and_proof/2]). +-export([add_chain/3, entries/2, entry_and_proof/2]). -export([known_roots/0, update_known_roots/0]). -export([init_cache_table/0]). +-export([entryhash_from_entry/1]). -include_lib("eunit/include/eunit.hrl"). -define(PROTOCOL_VERSION, 0). @@ -21,99 +22,202 @@ -record(timestamped_entry, {timestamp :: integer(), entry_type :: entry_type(), - signed_entry :: binary(), + signed_entry :: signed_x509_entry() | + signed_precert_entry(), extensions = <<>> :: binary()}). -type timestamped_entry() :: #timestamped_entry{}. --spec serialise(mtl() | timestamped_entry()) -> binary(). -serialise(#timestamped_entry{timestamp = Timestamp} = E) -> - list_to_binary( - [<<Timestamp:64>>, - serialise_entry_type(E#timestamped_entry.entry_type), - encode_tls_vector(E#timestamped_entry.signed_entry, 3), - encode_tls_vector(E#timestamped_entry.extensions, 2)]); +-record(signed_x509_entry, {asn1_cert :: binary()}). +-type signed_x509_entry() :: #signed_x509_entry{}. +-record(signed_precert_entry, {issuer_key_hash :: binary(), + tbs_certificate :: binary()}). +-type signed_precert_entry() :: #signed_precert_entry{}. + +-spec serialise(mtl() | timestamped_entry() | + signed_x509_entry() | signed_precert_entry()) -> binary(). +%% @doc Serialise a MerkleTreeLeaf as per RFC6962 Section 3.4. serialise(#mtl{leaf_version = LeafVersion, leaf_type = LeafType, entry = TimestampedEntry}) -> list_to_binary( [serialise_leaf_version(LeafVersion), serialise_leaf_type(LeafType), - serialise(TimestampedEntry)]). + serialise(TimestampedEntry)]); +%% @doc Serialise a TimestampedEntry as per RFC6962 Section 3.4. +serialise(#timestamped_entry{timestamp = Timestamp, + entry_type = EntryType, + signed_entry = SignedEntry, + extensions = Extensions}) -> + list_to_binary( + [<<Timestamp:64>>, + serialise_entry_type(EntryType), + serialise(SignedEntry), + encode_tls_vector(Extensions, 2)]); +%% @doc Serialise an ASN1.Cert as per RFC6962 Section 3.1. +serialise(#signed_x509_entry{asn1_cert = Cert}) -> + encode_tls_vector(Cert, 3); +%% @doc Serialise a PreCert as per RFC6962 Section 3.2. +serialise(#signed_precert_entry{ + issuer_key_hash = IssuerKeyHash, + tbs_certificate = TBSCertificate}) when is_binary(IssuerKeyHash), + size(IssuerKeyHash) == 32 -> + list_to_binary( + [IssuerKeyHash, + encode_tls_vector(TBSCertificate, 3)]). serialise_leaf_version(v1) -> <<0:8>>; serialise_leaf_version(v2) -> <<1:8>>. +deserialise_leaf_version(<<0:8>>) -> + v1; +deserialise_leaf_version(<<1:8>>) -> + v2. serialise_leaf_type(timestamped_entry) -> <<0:8>>. -%% serialise_leaf_type(_) -> -%% <<>>. +deserialise_leaf_type(<<0:8>>) -> + timestamped_entry. serialise_entry_type(x509_entry) -> <<0:16>>; serialise_entry_type(precert_entry) -> <<1:16>>. +deserialise_entry_type(<<0:16>>) -> + x509_entry; +deserialise_entry_type(<<1:16>>) -> + precert_entry. +-spec serialise_signature_type(certificate_timestamp|tree_hash) -> binary(). serialise_signature_type(certificate_timestamp) -> <<0:8>>; serialise_signature_type(tree_hash) -> <<1:8>>. -build_mtl(Timestamp, LeafCert) -> - TSE = #timestamped_entry{timestamp = Timestamp, - entry_type = x509_entry, - signed_entry = LeafCert}, - MTL = #mtl{leaf_version = v1, - leaf_type = timestamped_entry, - entry = TSE}, - serialise(MTL). - --spec add_chain(binary(), [binary()]) -> nonempty_string(). -add_chain(LeafCert, CertChain) -> +calc_sct(TimestampedEntry) -> + plop:serialise( + plop:spt(list_to_binary([<<?PROTOCOL_VERSION:8>>, + serialise_signature_type(certificate_timestamp), + serialise(TimestampedEntry)]))). + +get_sct(Hash, TimestampedEntry) -> + case application:get_env(catlfish, sctcache_root_path) of + {ok, RootPath} -> + case perm:readfile(RootPath, Hash) of + Contents when is_binary(Contents) -> + Contents; + noentry -> + SCT = calc_sct(TimestampedEntry), + ok = perm:ensurefile_nosync(RootPath, Hash, SCT), + SCT + end; + _ -> + calc_sct(TimestampedEntry) + end. + +-spec add_chain(binary(), [binary()], normal|precert) -> nonempty_string(). +add_chain(LeafCert, CertChain, Type) -> EntryHash = crypto:hash(sha256, [LeafCert | CertChain]), - TimestampedEntry = + EntryType = case Type of + normal -> x509_entry; + precert -> precert_entry + end, + {TimestampedEntry, Hash} = case plop:get(EntryHash) of notfound -> Timestamp = plop:generate_timestamp(), - TSE = #timestamped_entry{timestamp = Timestamp, - entry_type = x509_entry, - signed_entry = LeafCert}, - MTL = #mtl{leaf_version = v1, - leaf_type = timestamped_entry, - entry = TSE}, - ok = plop:add( - serialise_logentry(Timestamp, LeafCert, CertChain), - ht:leaf_hash(serialise(MTL)), - EntryHash), - TSE; - {_Index, _MTLHash, Entry} -> - <<Timestamp:64, _LogEntry/binary>> = Entry, - %% TODO: Perform a costly db consistency check against - %% unpacked LogEntry (w/ LeafCert and CertChain) - #timestamped_entry{timestamp = Timestamp, - entry_type = x509_entry, - signed_entry = LeafCert} + TSE = timestamped_entry(Timestamp, EntryType, LeafCert, CertChain), + MTLText = serialise(#mtl{leaf_version = v1, + leaf_type = timestamped_entry, + entry = TSE}), + MTLHash = ht:leaf_hash(MTLText), + ExtraData = + case Type of + normal -> CertChain; + precert -> [LeafCert | CertChain] + end, + LogEntry = + list_to_binary( + [encode_tls_vector(MTLText, 4), + encode_tls_vector( + encode_tls_vector( + list_to_binary( + [encode_tls_vector(C, 3) || C <- ExtraData]), + 3), + 4)]), + ok = plop:add(LogEntry, MTLHash, EntryHash), + {TSE, MTLHash}; + {_Index, MTLHash, DBEntry} -> + {MTLText, _ExtraData} = unpack_entry(DBEntry), + MTL = deserialise_mtl(MTLText), + MTLText = serialise(MTL), % verify FIXME: remove + {MTL#mtl.entry, MTLHash} end, - SCT_sig = - plop:spt(list_to_binary([<<?PROTOCOL_VERSION:8>>, - serialise_signature_type(certificate_timestamp), - serialise(TimestampedEntry)])), + + SCT_sig = get_sct(Hash, TimestampedEntry), {[{sct_version, ?PROTOCOL_VERSION}, {id, base64:encode(plop:get_logid())}, {timestamp, TimestampedEntry#timestamped_entry.timestamp}, {extensions, base64:encode(<<>>)}, - {signature, base64:encode(plop:serialise(SCT_sig))}]}. + {signature, base64:encode(SCT_sig)}]}. --spec serialise_logentry(integer(), binary(), [binary()]) -> binary(). -serialise_logentry(Timestamp, LeafCert, CertChain) -> - list_to_binary( - [<<Timestamp:64>>, - list_to_binary( - [encode_tls_vector(LeafCert, 3), - encode_tls_vector( - list_to_binary( - [encode_tls_vector(X, 3) || X <- CertChain]), 3)])]). +-spec timestamped_entry(integer(), entry_type(), binary(), binary()) -> + timestamped_entry(). +timestamped_entry(Timestamp, EntryType, LeafCert, CertChain) -> + SignedEntry = + case EntryType of + x509_entry -> + #signed_x509_entry{asn1_cert = LeafCert}; + precert_entry -> + {DetoxedLeafTBSCert, IssuerKeyHash} = + x509:detox(LeafCert, CertChain), + #signed_precert_entry{ + issuer_key_hash = IssuerKeyHash, + tbs_certificate = DetoxedLeafTBSCert} + end, + #timestamped_entry{timestamp = Timestamp, + entry_type = EntryType, + signed_entry = SignedEntry}. + +-spec deserialise_mtl(binary()) -> mtl(). +deserialise_mtl(Data) -> + <<LeafVersionBin:1/binary, + LeafTypeBin:1/binary, + TimestampedEntryBin/binary>> = Data, + #mtl{leaf_version = deserialise_leaf_version(LeafVersionBin), + leaf_type = deserialise_leaf_type(LeafTypeBin), + entry = deserialise_timestampedentry(TimestampedEntryBin)}. + +-spec deserialise_timestampedentry(binary()) -> timestamped_entry(). +deserialise_timestampedentry(Data) -> + <<Timestamp:64, EntryTypeBin:2/binary, RestData/binary>> = Data, + EntryType = deserialise_entry_type(EntryTypeBin), + {SignedEntry, ExtensionsBin} = + case EntryType of + x509_entry -> + deserialise_signed_x509_entry(RestData); + precert_entry -> + deserialise_signed_precert_entry(RestData) + end, + {Extensions, <<>>} = decode_tls_vector(ExtensionsBin, 2), + #timestamped_entry{timestamp = Timestamp, + entry_type = EntryType, + signed_entry = SignedEntry, + extensions = Extensions}. + +-spec deserialise_signed_x509_entry(binary()) -> {signed_x509_entry(), binary()}. +deserialise_signed_x509_entry(Data) -> + {E, D} = decode_tls_vector(Data, 3), + {#signed_x509_entry{asn1_cert = E}, D}. + +-spec deserialise_signed_precert_entry(binary()) -> + {signed_precert_entry(), binary()}. +deserialise_signed_precert_entry(Data) -> + <<IssuerKeyHash:32/binary, RestData/binary>> = Data, + {TBSCertificate, RestData2} = decode_tls_vector(RestData, 3), + {#signed_precert_entry{issuer_key_hash = IssuerKeyHash, + tbs_certificate = TBSCertificate}, + RestData2}. -spec entries(non_neg_integer(), non_neg_integer()) -> list(). entries(Start, End) -> @@ -123,10 +227,9 @@ entries(Start, End) -> entry_and_proof(Index, TreeSize) -> case plop:inclusion_and_entry(Index, TreeSize) of {ok, Entry, Path} -> - {Timestamp, LeafCertVector, CertChainVector} = unpack_entry(Entry), - MTL = build_mtl(Timestamp, LeafCertVector), + {MTL, ExtraData} = unpack_entry(Entry), {[{leaf_input, base64:encode(MTL)}, - {extra_data, base64:encode(CertChainVector)}, + {extra_data, base64:encode(ExtraData)}, {audit_path, [base64:encode(X) || X <- Path]}]}; {notfound, Msg} -> {[{success, false}, @@ -141,21 +244,45 @@ init_cache_table() -> end, ets:new(?CACHE_TABLE, [set, public, named_table]). +deserialise_extra_data(ExtraData) -> + case decode_tls_vector(ExtraData, 3) of + {E, <<>>} -> + [E]; + {E, Rest} -> + [E | deserialise_extra_data(Rest)] + end. + +entryhash_from_entry(Entry) -> + {MTLText, ExtraDataPacked} = unpack_entry(Entry), + {ExtraData, <<>>} = decode_tls_vector(ExtraDataPacked, 3), + MTL = deserialise_mtl(MTLText), + TimestampedEntry = MTL#mtl.entry, + Chain = deserialise_extra_data(ExtraData), + Data = + case TimestampedEntry#timestamped_entry.entry_type of + x509_entry -> + SignedEntry = TimestampedEntry#timestamped_entry.signed_entry, + [SignedEntry#signed_x509_entry.asn1_cert | Chain]; + precert_entry -> + Chain + end, + crypto:hash(sha256, Data). + %% Private functions. +-spec unpack_entry(binary()) -> {binary(), binary()}. unpack_entry(Entry) -> - <<Timestamp:64, LogEntry/binary>> = Entry, - {LeafCertVector, CertChainVector} = decode_tls_vector(LogEntry, 3), - {Timestamp, LeafCertVector, CertChainVector}. + {MTL, Rest} = decode_tls_vector(Entry, 4), + {ExtraData, <<>>} = decode_tls_vector(Rest, 4), + {MTL, ExtraData}. -spec x_entries([{non_neg_integer(), binary(), binary()}]) -> list(). x_entries([]) -> []; x_entries([H|T]) -> {_Index, _Hash, Entry} = H, - {Timestamp, LeafCertVector, CertChainVector} = unpack_entry(Entry), - MTL = build_mtl(Timestamp, LeafCertVector), - [{[{leaf_input, base64:encode(MTL)}, {extra_data, base64:encode(CertChainVector)}]} | - x_entries(T)]. + {MTL, ExtraData} = unpack_entry(Entry), + [{[{leaf_input, base64:encode(MTL)}, + {extra_data, base64:encode(ExtraData)}]} | x_entries(T)]. -spec encode_tls_vector(binary(), non_neg_integer()) -> binary(). encode_tls_vector(Binary, LengthLen) -> diff --git a/src/catlfish_sup.erl b/src/catlfish_sup.erl index 6f918cd..882a017 100644 --- a/src/catlfish_sup.erl +++ b/src/catlfish_sup.erl @@ -9,6 +9,21 @@ start_link(_Args) -> supervisor:start_link({local, ?MODULE}, ?MODULE, []). +gen_http_config(Config, SSLOptions, SSLFlag) -> + {ChildName, IpAddress, Port, Module} = Config, + {ok, IPv4Address} = + inet:parse_ipv4strict_address(IpAddress), + WebConfig = [{ip, IPv4Address}, + {port, Port}, + {ssl, SSLFlag}, + {acceptor_pool_size, application:get_env(catlfish, http_server_pool_size, 16)}, + {ssl_opts, SSLOptions} + ], + {ChildName, + {catlfish_web, start, [WebConfig, Module, ChildName]}, + permanent, 5000, + worker, dynamic}. + init([]) -> SSLOptions = [{certfile, application:get_env(catlfish, https_certfile, none)}, @@ -16,20 +31,11 @@ init([]) -> {cacertfile, application:get_env(catlfish, https_cacertfile, none)}], Servers = lists:map(fun (Config) -> - {ChildName, IpAddress, Port, Module} = Config, - {ok, IPv4Address} = - inet:parse_ipv4strict_address(IpAddress), - WebConfig = [{ip, IPv4Address}, - {port, Port}, - {ssl, true}, - {acceptor_pool_size, application:get_env(catlfish, http_server_pool_size, 16)}, - {ssl_opts, SSLOptions} - ], - {ChildName, - {catlfish_web, start, [WebConfig, Module]}, - permanent, 5000, - worker, dynamic} - end, application:get_env(catlfish, https_servers, [])), + gen_http_config(Config, SSLOptions, true) + end, application:get_env(catlfish, https_servers, [])) ++ + lists:map(fun (Config) -> + gen_http_config(Config, SSLOptions, false) + end, application:get_env(catlfish, http_servers, [])), lager:debug("Starting servers ~p", [Servers]), {ok, {{one_for_one, 3, 10}, diff --git a/src/catlfish_web.erl b/src/catlfish_web.erl index 5ee5743..f9fe6d6 100644 --- a/src/catlfish_web.erl +++ b/src/catlfish_web.erl @@ -2,14 +2,14 @@ %%% See LICENSE for licensing information. -module(catlfish_web). --export([start/2, loop/2]). +-export([start/3, loop/2]). -start(Options, Module) -> +start(Options, Module, Name) -> lager:debug("Starting catlfish web server: ~p", [Module]), Loop = fun (Req) -> ?MODULE:loop(Req, Module) end, - mochiweb_http:start([{name, Module}, {loop, Loop} | Options]). + mochiweb_http:start([{name, Name}, {loop, Loop} | Options]). add_auth(Path, {Code, Headers, Data}) -> @@ -9,38 +9,19 @@ %% Public functions, i.e. part of URL. request(post, "ct/v1/add-chain", Input) -> - case (catch mochijson2:decode(Input)) of - {error, E} -> - html("add-chain: bad input:", E); - {struct, [{<<"chain">>, ChainBase64}]} -> - case (catch [base64:decode(X) || X <- ChainBase64]) of - {'EXIT', _} -> - html("add-chain: invalid base64-encoded chain: ", - [ChainBase64]); - [LeafCert | CertChain] -> - Roots = catlfish:known_roots(), - case x509:normalise_chain(Roots, [LeafCert|CertChain]) of - {ok, [Leaf | Chain]} -> - lager:info("adding ~p", - [x509:cert_string(LeafCert)]), - success(catlfish:add_chain(Leaf, Chain)); - {error, Reason} -> - lager:info("rejecting ~p: ~p", - [x509:cert_string(LeafCert), Reason]), - html("add-chain: invalid chain", Reason) - end; - Invalid -> - html("add-chain: chain is not a list: ", [Invalid]) - end; - _ -> html("add-chain: missing input: chain", Input) - end; + add_chain(Input, normal); -request(post, "ct/v1/add-pre-chain", _Input) -> - niy(); +request(post, "ct/v1/add-pre-chain", Input) -> + add_chain(Input, precert); request(get, "ct/v1/get-sth", _Query) -> - R = plop:sth(), - success(R); + case plop:sth() of + noentry -> + lager:error("No valid STH found"), + internalerror("No valid STH found"); + R -> + success(R) + end; request(get, "ct/v1/get-sth-consistency", Query) -> case lists:sort(Query) of @@ -130,8 +111,40 @@ html(Text, Input) -> "~p~n" ++ "</body></html>~n", [Text, Input])}. -niy() -> - html("NIY - Not Implemented Yet|", []). - success(Data) -> {200, [{"Content-Type", "text/json"}], mochijson2:encode(Data)}. + +internalerror(Text) -> + {500, [{"Content-Type", "text/html"}], + io_lib:format( + "<html><body><p>~n" ++ + "~s~n" ++ + "</body></html>~n", [Text])}. + +-spec add_chain(any(), normal|precert) -> any(). +add_chain(Input, Type) -> + case (catch mochijson2:decode(Input)) of + {error, E} -> + html("add-chain: bad input:", E); + {struct, [{<<"chain">>, ChainBase64}]} -> + case (catch [base64:decode(X) || X <- ChainBase64]) of + {'EXIT', _} -> + html("add-chain: invalid base64-encoded chain: ", + [ChainBase64]); + [LeafCert | CertChain] -> + case x509:normalise_chain(catlfish:known_roots(), + [LeafCert|CertChain]) of + {ok, [Leaf | Chain]} -> + lager:info("adding ~p cert ~p", + [Type, x509:cert_string(LeafCert)]), + success(catlfish:add_chain(Leaf, Chain, Type)); + {error, Reason} -> + lager:info("rejecting ~p: ~p", + [x509:cert_string(LeafCert), Reason]), + html("add-chain: invalid chain", Reason) + end; + Invalid -> + html("add-chain: chain is not a list: ", [Invalid]) + end; + _ -> html("add-chain: missing input: chain", Input) + end. diff --git a/src/x509.erl b/src/x509.erl index 5a0e871..1b0db5e 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,45 +127,59 @@ encoded_tbs_cert(DerCert) -> PKIXCert, EncodedTBSCert. -%% Code from pubkey_cert:extract_verify_data/2. --spec verifydata_from_cert(#'Certificate'{}, binary()) -> {ok, tuple()} | error. -verifydata_from_cert(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}} - catch - error:function_clause -> - lager:debug("signature algorithm not supported: ~p", [SigAlg]), +-spec decode_cert(binary()) -> #'Certificate'{} | error. +decode_cert(Der) -> + case (catch public_key:pkix_decode_cert(Der, plain)) of + #'Certificate'{} = Cert -> + Cert; + {'EXIT', Reason} -> + lager:info("invalid certificate: ~p: ~p", [cert_string(Der), Reason]), + dump_unparsable_cert(Der), + error; + Unknown -> + lager:info("unknown error decoding cert: ~p: ~p", + [cert_string(Der), Unknown]), error end. -%% @doc Verify that Cert/DerCert is signed by Issuer. --spec verify_sig(#'Certificate'{}, binary(), #'Certificate'{}) -> boolean(). -verify_sig(Cert, DerCert, % Certificate to verify. - #'Certificate'{ % Issuer. - tbsCertificate = #'TBSCertificate'{ - subjectPublicKeyInfo = IssuerSPKI}}) -> - %% Dig out digest, digest type and signature from Cert/DerCert. - case verifydata_from_cert(Cert, DerCert) of - error -> false; - {ok, Tuple} -> verify_sig2(IssuerSPKI, Tuple) +parsable_cert_p(Der) -> + case decode_cert(Der) of + error -> + false; + _ -> + true + end. + +%% @doc Is Cert signed by Issuer? Only verify that the signature +%% matches and don't check things like Cert.issuer == Issuer.subject. +-spec signed_by_p(binary(), binary()) -> boolean(). +signed_by_p(SubjectDer, IssuerDer) -> + SubjectCert = decode_cert(SubjectDer), + IssuerCert = decode_cert(IssuerDer), + + case {SubjectCert, IssuerCert} of + {#'Certificate'{}, + #'Certificate'{tbsCertificate = + #'TBSCertificate'{subjectPublicKeyInfo = + IssuerSPKI}}} -> + %% Dig out digest, digest type and signature from subject cert and + %% verify signature. + case extract_verify_data(decode_cert(SubjectDer), SubjectDer) of + error -> + false; + {ok, SubjectData} -> + verify_sig(IssuerSPKI, SubjectData) + end; + _ -> + false end. -verify_sig2(IssuerSPKI, {DigestOrPlainText, DigestType, Signature}) -> - %% Dig out issuer key from issuer cert. +verify_sig(IssuerSPKI, {DigestOrPlainText, DigestType, Signature}) -> + %% Dig out alg, params and key from issuer. #'SubjectPublicKeyInfo'{ 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' -> @@ -137,96 +188,166 @@ verify_sig2(IssuerSPKI, {DigestOrPlainText, DigestType, Signature}) -> Point = #'ECPoint'{point = Key0}, ECParams = public_key:der_decode('EcpkParameters', Params), {Point, ECParams}; - _ -> % FIXME: 'DSAPublicKey' + _ -> % FIXME: 'DSAPublicKey' 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). -%% @doc Is Cert signed by Issuer? Only verify that the signature -%% matches and don't check things like Cert.issuer == Issuer.subject. --spec signed_by_p(binary(), binary()) -> boolean(). -signed_by_p(DerCert, IssuerDerCert) when is_binary(DerCert), - is_binary(IssuerDerCert) -> - verify_sig(public_key:pkix_decode_cert(DerCert, plain), - 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'{} -> - true; - {'EXIT', Reason} -> - lager:info("invalid certificate: ~p: ~p", [cert_string(Der), Reason]), - false; - Unknown -> - lager:info("unknown error decoding cert: ~p: ~p", - [cert_string(Der), Unknown]), - false +-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. +extract_verify_data(Cert, DerCert) -> + PlainText = encoded_tbs_cert(DerCert), + {_, Sig} = Cert#'Certificate'.signature, + SigAlgRecord = Cert#'Certificate'.signatureAlgorithm, + SigAlg = SigAlgRecord#'AlgorithmIdentifier'.algorithm, + try + {DigestType, _} = public_key:pkix_sign_types(SigAlg), + {ok, {PlainText, DigestType, Sig}} + catch + error:function_clause -> + lager:debug("~p: signature algorithm not supported: ~p", + [cert_string(DerCert), SigAlg]), + error 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 +359,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} -> @@ -259,18 +381,18 @@ pems_from_file(Filename) -> Pems. -spec dump_unparsable_cert(binary()) -> ok | {error, atom()} | not_logged. -dump_unparsable_cert(CertDer) -> +dump_unparsable_cert(Der) -> case application:get_env(catlfish, rejected_certs_path) of {ok, Directory} -> {NowMegaSec, NowSec, NowMicroSec} = now(), Filename = filename:join(Directory, io_lib:format("~p:~p.~p", - [cert_string(CertDer), + [cert_string(Der), NowMegaSec * 1000 * 1000 + NowSec, NowMicroSec])), - lager:debug("dumping cert to ~p~n", [Filename]), - file:write_file(Filename, CertDer); + lager:info("dumping cert to ~p~n", [Filename]), + file:write_file(Filename, Der); _ -> not_logged end. @@ -292,36 +414,64 @@ valid_cert_test_() -> fun({KnownRoots, Chains}) -> [ %% Self-signed but verified against itself so pass. - %% Not a valid OTPCertificate: - %% {error,{asn1,{invalid_choice_tag,{22,<<"US">>}}}} - %% '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)), + %% Note that this certificate is rejected by the + %% stricter OTP-PKIX.asn1 specification generating + %% #'OTPCertificate'{}. The error is + %% {invalid_choice_tag,{22,<<"US">>}}}} in + %% 'OTP-PUB-KEY':Func('OTP-X520countryname', Value0). + ?_assertMatch({true, _}, normalise_chain(nth(1, Chains), + nth(1, Chains), 10)), %% Self-signed so fail. ?_assertMatch({false, root_unknown}, - valid_chain_p(KnownRoots, - lists:nth(2, Chains), 10)), + normalise_chain(KnownRoots, + 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, + 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, + nth(4, Chains), 3)), %% Verify against self, pass. %% Bug CATLFISH-??, can't handle issuer keytype ECPoint. %% Issuer sha1: 6969562e4080f424a1e7199f14baf3ee58ab6abb - ?_assertMatch(true, signed_by_p(hd(lists:nth(5, Chains)), - hd(lists:nth(5, Chains)))), + ?_assertMatch(true, signed_by_p(hd(nth(5, Chains)), + hd(nth(5, Chains)))), %% Unsupported signature algorithm MD2-RSA, fail. %% Signature Algorithm: md2WithRSAEncryption %% CA cert with sha1 96974cd6b663a7184526b1d648ad815cf51e801a - ?_assertMatch(false, signed_by_p(hd(lists:nth(6, Chains)), - hd(lists:nth(6, Chains)))) + ?_assertMatch(false, signed_by_p(hd(nth(6, Chains)), + hd(nth(6, Chains)))), + + %% Supposedly problematic chains from Google Aviator, fatal. + %% 00459972: asn1: syntax error: sequence truncated + ?_assertMatch({true, _}, normalise_chain(nth(7, Chains), + nth(7, Chains), 10)), + %% 1402673: x509: RSA modulus is not a positive number + ?_assertMatch({true, _}, normalise_chain(nth(8, Chains), + nth(8, Chains), 10)), + %% 1345105: asn1: syntax error: IA5String contains invalid character + ?_assertMatch({true, _}, normalise_chain(nth(9, Chains), + nth(9, Chains), 10)), + %% 1557693: asn1: structure error: integer too large + ?_assertMatch({true, _}, normalise_chain(nth(10, Chains), + nth(10, Chains), 10)), + + %% Supposedly problematic chains from Google Aviator, non-fatal. + %% 16800: x509: negative serial number + %% a.pem + ?_assertMatch({true, _}, normalise_chain(nth(11, Chains), + nth(11, Chains), 10)), + %% 22487: x509: unhandled critical extension ([2 5 29 32]) + %% b.pem + ?_assertMatch({true, _}, normalise_chain(nth(12, Chains), + nth(12, Chains), 10)), + %% 5198: x509: certificate contained IP address of length 8 + %% c.pem + ?_assertMatch({true, _}, normalise_chain(nth(13, Chains), + nth(13, Chains), 10)) ] end}. chain_test_() -> @@ -333,21 +483,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()]. |