Skip to main content

src/barrel_p2p_quic_cert.erl

%%% -*- erlang -*-
%%% Copyright (c) 2026 Benoit Chesneau
%%% SPDX-License-Identifier: Apache-2.0
%%%
-module(barrel_p2p_quic_cert).

%% X.509 Self-Signed Certificate Generation for QUIC TLS
%%
%% Generates self-signed certificates for QUIC distribution encryption.
%% Uses public_key application for certificate creation.
%%
%% The certificates are self-signed because QUIC/TLS only provides
%% encryption - actual node authentication is done via Erlang cookie
%% (handled by dist_util in erlang_quic).

-include_lib("public_key/include/public_key.hrl").

-export([
    ensure_cert/0,
    ensure_cert/1,
    generate_cert/1,
    get_cert_paths/0
]).

%% Default certificate parameters
-define(DEFAULT_DAYS, 365).
%% Backdate notBefore so a peer whose clock lags slightly does not see a
%% not-yet-valid cert.
-define(BACKDATE_SECONDS, 300).
-define(DEFAULT_CERT_DIR, "data/quic").

%%====================================================================
%% API
%%====================================================================

%% @doc Ensure QUIC certificates exist, generating them if needed.
%% Uses default directory from application config.
-spec ensure_cert() -> ok | {error, term()}.
ensure_cert() ->
    CertDir = application:get_env(barrel_p2p, quic_cert_dir, ?DEFAULT_CERT_DIR),
    ensure_cert(CertDir).

%% @doc Ensure QUIC certificates exist in the specified directory.
-spec ensure_cert(file:filename()) -> ok | {error, term()}.
ensure_cert(CertDir) ->
    CertFile = filename:join(CertDir, "node.crt"),
    KeyFile = filename:join(CertDir, "node.key"),
    case filelib:is_regular(CertFile) andalso filelib:is_regular(KeyFile) of
        true ->
            %% Certificates exist
            ok;
        false ->
            %% Generate new certificates
            generate_cert(CertDir)
    end.

%% @doc Generate a new self-signed certificate and key.
-spec generate_cert(file:filename()) -> ok | {error, term()}.
generate_cert(CertDir) ->
    %% Ensure directory exists
    ok = filelib:ensure_dir(filename:join(CertDir, "dummy")),

    %% Generate EC key pair (P-256)
    case generate_key() of
        {ok, PrivateKey} ->
            %% Create self-signed certificate
            case create_certificate(PrivateKey) of
                {ok, Cert} ->
                    %% Write files
                    CertFile = filename:join(CertDir, "node.crt"),
                    KeyFile = filename:join(CertDir, "node.key"),
                    case write_cert_files(CertFile, KeyFile, Cert, PrivateKey) of
                        ok ->
                            logger:info("QUIC certificates generated in ~s", [CertDir]),
                            ok;
                        Error ->
                            Error
                    end;
                Error ->
                    Error
            end;
        Error ->
            Error
    end.

%% @doc Get the paths to the certificate and key files.
-spec get_cert_paths() -> {CertFile :: file:filename(), KeyFile :: file:filename()}.
get_cert_paths() ->
    CertDir = application:get_env(barrel_p2p, quic_cert_dir, ?DEFAULT_CERT_DIR),
    {filename:join(CertDir, "node.crt"), filename:join(CertDir, "node.key")}.

%%====================================================================
%% Internal Functions
%%====================================================================

%% @private
%% Generate an EC key pair on the NIST P-256 curve. The returned
%% ECPrivateKey carries the public point in its `publicKey' field.
generate_key() ->
    try
        PrivateKey = public_key:generate_key({namedCurve, ?'secp256r1'}),
        {ok, PrivateKey}
    catch
        _:Reason ->
            {error, {key_generation_failed, Reason}}
    end.

%% @private
%% Create a self-signed X.509 certificate over an EC (P-256) key.
create_certificate(PrivateKey) ->
    try
        %% Get node name for CN
        NodeName =
            case node() of
                nonode@nohost -> "barrel_p2p-node";
                Node -> atom_to_list(Node)
            end,

        %% Build subject
        Subject =
            {rdnSequence, [
                [
                    #'AttributeTypeAndValue'{
                        type = ?'id-at-commonName',
                        value = dir_string(NodeName)
                    }
                ],
                [
                    #'AttributeTypeAndValue'{
                        type = ?'id-at-organizationName',
                        value = dir_string("Barrel P2P")
                    }
                ]
            ]},

        %% Validity period. Each bound is encoded as UTCTime for
        %% years <2050 and GeneralizedTime for 2050+ per RFC 5280.
        %% notBefore is backdated a few minutes for peer clock skew.
        Now = calendar:universal_time(),
        Validity = #'Validity'{
            notBefore = validity_time(add_seconds(Now, -?BACKDATE_SECONDS)),
            notAfter = validity_time(add_days(Now, ?DEFAULT_DAYS))
        },

        %% Serial number: positive 127-bit integer drawn from a
        %% cryptographic PRNG. Mask off the top bit so the ASN.1
        %% INTEGER encoding stays positive without an extra byte.
        SerialBytes = crypto:strong_rand_bytes(16),
        Serial =
            binary:decode_unsigned(SerialBytes) band
                ((1 bsl 127) - 1),

        %% Subject public key info for an EC (P-256) key. The algorithm
        %% is id-ecPublicKey with the named-curve parameters; the public
        %% key is the raw EC point carried in the generated private key.
        #'ECPrivateKey'{publicKey = ECPoint} = PrivateKey,
        SubjectPKInfo = #'SubjectPublicKeyInfo'{
            algorithm = #'AlgorithmIdentifier'{
                algorithm = ?'id-ecPublicKey',
                parameters = ec_named_curve_params()
            },
            subjectPublicKey = ECPoint
        },

        %% ecdsa-with-SHA256 has no algorithm parameters (absent).
        SigAlg = #'AlgorithmIdentifier'{algorithm = ?'ecdsa-with-SHA256'},

        %% TBS Certificate
        TBSCert = #'TBSCertificate'{
            version = v3,
            serialNumber = Serial,
            signature = SigAlg,
            issuer = Subject,
            validity = Validity,
            subject = Subject,
            subjectPublicKeyInfo = SubjectPKInfo,
            extensions = create_extensions()
        },

        %% Sign the certificate
        TBSDer = public_key:der_encode('TBSCertificate', TBSCert),
        Signature = public_key:sign(TBSDer, sha256, PrivateKey),

        %% Build final certificate
        Cert = #'Certificate'{
            tbsCertificate = TBSCert,
            signatureAlgorithm = SigAlg,
            signature = Signature
        },

        {ok, Cert}
    catch
        _:Reason ->
            {error, {certificate_creation_failed, Reason}}
    end.

%% @private
%% Create X.509 v3 extensions.
create_extensions() ->
    [
        %% Basic Constraints: CA:FALSE
        #'Extension'{
            extnID = ?'id-ce-basicConstraints',
            critical = true,
            extnValue = public_key:der_encode(
                'BasicConstraints',
                #'BasicConstraints'{cA = false}
            )
        },
        %% Key Usage: Digital Signature (EC key; no keyEncipherment).
        #'Extension'{
            extnID = ?'id-ce-keyUsage',
            critical = true,
            extnValue = public_key:der_encode(
                'KeyUsage',
                [digitalSignature]
            )
        },
        %% Extended Key Usage: TLS Server Auth, TLS Client Auth
        #'Extension'{
            extnID = ?'id-ce-extKeyUsage',
            critical = false,
            extnValue = public_key:der_encode(
                'ExtKeyUsageSyntax',
                [?'id-kp-serverAuth', ?'id-kp-clientAuth']
            )
        }
    ].

%% @private
%% Write certificate and key to PEM files. The private key goes
%% through the atomic chmod-before-write helper so it never lives on
%% disk world-readable while it contains secret material.
write_cert_files(CertFile, KeyFile, Cert, PrivateKey) ->
    try
        CertDer = public_key:der_encode('Certificate', Cert),
        CertPem = public_key:pem_encode([{'Certificate', CertDer, not_encrypted}]),
        KeyDer = public_key:der_encode('ECPrivateKey', PrivateKey),
        KeyPem = public_key:pem_encode([{'ECPrivateKey', KeyDer, not_encrypted}]),
        ok = file:write_file(CertFile, CertPem),
        case barrel_p2p_file:write_secure(KeyFile, KeyPem) of
            ok -> ok;
            {error, _} = Error -> Error
        end
    catch
        _:Reason ->
            {error, {file_write_failed, Reason}}
    end.

%% @private
%% Build an X.509 validity bound. Years < 2050 use UTCTime
%% (YYMMDDHHMMSSZ); years >= 2050 use GeneralizedTime
%% (YYYYMMDDHHMMSSZ) per RFC 5280 4.1.2.5.
validity_time({{Year, _, _}, _} = DateTime) when Year >= 2050 ->
    {generalTime, format_general_time(DateTime)};
validity_time(DateTime) ->
    {utcTime, format_utc_time(DateTime)}.

format_utc_time({{Year, Month, Day}, {Hour, Min, Sec}}) ->
    Y = Year rem 100,
    lists:flatten(
        io_lib:format(
            "~2..0w~2..0w~2..0w~2..0w~2..0w~2..0wZ",
            [Y, Month, Day, Hour, Min, Sec]
        )
    ).

format_general_time({{Year, Month, Day}, {Hour, Min, Sec}}) ->
    lists:flatten(
        io_lib:format(
            "~4..0w~2..0w~2..0w~2..0w~2..0w~2..0wZ",
            [Year, Month, Day, Hour, Min, Sec]
        )
    ).

%% @private
%% Encode a DirectoryString attribute value in the form the local
%% public_key/asn1 backend accepts. OTP >= 28 (PKIX1Explicit-2009) takes
%% the typed CHOICE tuple; OTP =< 27 (OTP-PUB-KEY) treats the
%% AttributeTypeAndValue value as an open type and wants pre-encoded DER.
dir_string(Str) ->
    Bin = unicode:characters_to_binary(Str),
    case otp_major() >= 28 of
        true -> {utf8String, Bin};
        false -> public_key:der_encode('X520CommonName', {utf8String, Bin})
    end.

otp_major() ->
    try
        list_to_integer(erlang:system_info(otp_release))
    catch
        _:_ -> 0
    end.

%% @private
%% The SubjectPublicKeyInfo EC parameters, in the form the local asn1
%% backend accepts: OTP >= 28 takes the ECParameters CHOICE; OTP =< 27
%% treats it as an open type and wants pre-encoded DER.
ec_named_curve_params() ->
    Curve = {namedCurve, ?'secp256r1'},
    case otp_major() >= 28 of
        true -> Curve;
        false -> public_key:der_encode('EcpkParameters', Curve)
    end.

%% @private
%% Add days to a datetime.
add_days(DateTime, Days) ->
    add_seconds(DateTime, Days * 24 * 60 * 60).

%% @private
%% Add (or, with a negative value, subtract) seconds to a datetime.
add_seconds(DateTime, Seconds) ->
    Base = calendar:datetime_to_gregorian_seconds(DateTime),
    calendar:gregorian_seconds_to_datetime(Base + Seconds).