%%% -*- erlang -*- %%% %%% Server certificate validation for QUIC clients. %%% %%% Performs the two checks RFC 8446 requires of a client after it %%% receives the server's Certificate message: chain validation against %%% a set of trust anchors (ยง4.4.2.4) and identity (hostname) matching %%% (RFC 6125). Signature verification of CertificateVerify is handled %%% in `quic_tls:verify_certificate_verify/4'; this module covers the %%% chain and the name. -module(quic_cert). -include_lib("public_key/include/public_key.hrl"). -export([validate_server/4]). -define(MAX_PATH_LENGTH, 10). %% @doc Validate a server's certificate chain and identity. %% %% `Leaf' is the server's end-entity certificate (DER). `Intermediates' %% are the remaining certificates sent by the peer (DER), in the %% leaf-to-root order they arrive on the wire. `CaCerts' are the trust %% anchors as a DER list, or `undefined' to use the OS trust store. %% `ServerName' is the expected identity (binary hostname or IP literal), %% or `undefined' to skip the hostname check. -spec validate_server( binary() | undefined, [binary()], [binary()] | undefined, binary() | undefined ) -> ok | {error, term()}. validate_server(undefined, _Intermediates, _CaCerts, _ServerName) -> {error, no_certificate}; validate_server(Leaf, Intermediates, CaCerts, ServerName) when is_binary(Leaf) -> case verify_chain(Leaf, Intermediates, trust_anchors(CaCerts)) of ok -> verify_hostname(Leaf, ServerName); {error, _} = Error -> Error end. %%==================================================================== %% Chain validation %%==================================================================== %% Trust anchors as DER. `cacerts_get/0' returns `#cert{}' records on %% recent OTP and bare DER on older ones; normalise both. trust_anchors(undefined) -> [normalize_anchor(C) || C <- safe_cacerts_get()]; trust_anchors(CaCerts) when is_list(CaCerts) -> [normalize_anchor(C) || C <- CaCerts]. safe_cacerts_get() -> try public_key:cacerts_get() catch _:_ -> [] end. normalize_anchor(#cert{der = Der}) -> Der; normalize_anchor(Der) when is_binary(Der) -> Der. verify_chain(_Leaf, _Intermediates, []) -> {error, no_trust_anchors}; verify_chain(Leaf, Intermediates, Anchors) -> %% `pkix_path_validation/3' wants the path ordered from the cert %% issued by the anchor down to the leaf; the wire order is the %% reverse (leaf first). Path = lists:reverse([Leaf | Intermediates]), case candidate_anchorings(Path, Anchors) of [] -> {error, unknown_ca}; [Primary | Alternatives] -> validate_anchored(Primary, Alternatives) end. %% Every `{Anchor, SubPath}' pairing, highest anchorable cert first. The %% head is the pairing the single-anchor lookup would have chosen (lets %% the server send extra or cross-signed certs above the cert that %% actually chains); the tail anchors lower in the served chain, or at a %% different trust anchor for the same cert. candidate_anchorings([], _Anchors) -> []; candidate_anchorings([Cert | Rest] = SubPath, Anchors) -> [{Anchor, SubPath} || Anchor <- Anchors, is_issuer(Cert, Anchor)] ++ candidate_anchorings(Rest, Anchors). %% Validate the primary anchoring; on an expired-cert failure, recover by %% trying the alternatives. Mirrors OTP's %% `ssl_certificate:find_cross_sign_root_paths/4': an expired cross-signed %% root (e.g. Let's Encrypt ISRG Root X2 cross-signed by the expired X1) %% is dropped for a still-valid trust anchor with the same key, reached %% lower in the chain. Recovery only moves the anchor; an expired leaf or %% intermediate stays in every remaining sub-path, so it still fails as %% `cert_expired'. validate_anchored(Primary, Alternatives) -> case validate_path(Primary) of ok -> ok; {error, {bad_cert, Reason}} = Error -> case is_expiry(Reason) of true -> case try_anchorings(Alternatives) of ok -> ok; error -> {error, {bad_cert, cert_expired}} end; false -> Error end end. %% First anchoring that validates wins. Returns a bare `error' on %% exhaustion, discarding each alternative's reason, so a failing %% alternative never replaces the caller's expiry outcome. try_anchorings([]) -> error; try_anchorings([Candidate | Rest]) -> case validate_path(Candidate) of ok -> ok; {error, _} -> try_anchorings(Rest) end. validate_path({Anchor, SubPath}) -> try public_key:pkix_path_validation( Anchor, SubPath, [{max_path_length, ?MAX_PATH_LENGTH}] ) of {ok, _} -> ok; {error, {bad_cert, Reason}} -> {error, {bad_cert, Reason}} catch _:_ -> {error, {bad_cert, validation_failed}} end. is_expiry(cert_expired) -> true; is_expiry(root_cert_expired) -> true; is_expiry(_) -> false. %% A self-signed cert is its own issuer, which covers self-signed leaves %% supplied as anchors. is_issuer(Cert, Anchor) -> try public_key:pkix_is_issuer(Cert, Anchor) catch _:_ -> false end. %%==================================================================== %% Hostname validation %%==================================================================== verify_hostname(_Leaf, undefined) -> ok; verify_hostname(Leaf, ServerName) when is_binary(ServerName) -> Host = binary_to_list(ServerName), RefId = case inet:parse_address(Host) of {ok, IP} -> {ip, IP}; {error, _} -> {dns_id, Host} end, case safe_verify_hostname(Leaf, RefId) of true -> ok; false -> {error, {hostname_mismatch, ServerName}} end. safe_verify_hostname(Leaf, RefId) -> try public_key:pkix_verify_hostname(Leaf, [RefId]) catch _:_ -> false end.