Skip to main content

src/barrel_p2p_rotate.erl

%%% -*- erlang -*-
%%% Copyright (c) 2026 Benoit Chesneau
%%% SPDX-License-Identifier: Apache-2.0
%%%
%%% Cert and identity rotation. Two flavours:
%%%
%%%   rotate_cert/0,1     -- regenerate the QUIC TLS cert/key pair.
%%%                          Takes effect on the next listener bind, so a
%%%                          node restart is required to pick the new
%%%                          credentials up. The atomic swap is still
%%%                          done in place so a crash mid-rotation does
%%%                          not leave torn files.
%%%
%%%   rotate_identity/0,1 -- regenerate the Ed25519 identity keypair.
%%%                          Takes effect on the next auth handshake
%%%                          because barrel_p2p_dist_auth reads the keys
%%%                          off disk per attempt. No restart needed,
%%%                          but peers running in strict-trust mode will
%%%                          reject the new identity until re-pinned.
%%%
%%% Both flavours back the previous material up under a `backups/`
%%% subdirectory of the relevant key/cert directory, keyed by ISO-8601
%%% timestamp. The backup path is returned to the caller so a runbook
%%% can roll back deterministically.

-module(barrel_p2p_rotate).

-export([
    rotate_cert/0, rotate_cert/1,
    rotate_identity/0, rotate_identity/1
]).

%%====================================================================
%% Public API
%%====================================================================

-type result() ::
    {ok, #{
        cert_file => file:filename(),
        key_file => file:filename(),
        backup_dir => file:filename() | undefined,
        restart_required => boolean()
    }}
    | {error, term()}.

-spec rotate_cert() -> result().
rotate_cert() ->
    rotate_cert(#{}).

-spec rotate_cert(map()) -> result().
rotate_cert(Opts) ->
    Dir = maps:get(
        cert_dir,
        Opts,
        application:get_env(
            barrel_p2p,
            quic_cert_dir,
            "data/quic"
        )
    ),
    CertFile = filename:join(Dir, "node.crt"),
    KeyFile = filename:join(Dir, "node.key"),
    case backup_pair(Dir, [CertFile, KeyFile]) of
        {ok, BackupDir} ->
            case barrel_p2p_quic_cert:generate_cert(Dir) of
                ok ->
                    logger:warning(
                        "barrel_p2p_rotate: cert rotated in ~s; "
                        "node restart required for the listener "
                        "to load the new credentials. Backup at ~s",
                        [Dir, BackupDir]
                    ),
                    {ok, #{
                        cert_file => CertFile,
                        key_file => KeyFile,
                        backup_dir => BackupDir,
                        restart_required => true
                    }};
                Error ->
                    restore_backup(BackupDir, [CertFile, KeyFile]),
                    Error
            end;
        Error ->
            Error
    end.

-spec rotate_identity() -> result().
rotate_identity() ->
    rotate_identity(#{}).

-spec rotate_identity(map()) -> result().
rotate_identity(Opts) ->
    Dir = maps:get(
        key_dir,
        Opts,
        application:get_env(barrel_p2p, auth_key_dir, "data/keys")
    ),
    PubFile = filename:join(Dir, "node.pub"),
    PrivFile = filename:join(Dir, "node.key"),
    case backup_pair(Dir, [PubFile, PrivFile]) of
        {ok, BackupDir} ->
            {PubKey, PrivKey} = barrel_p2p_dist_auth:generate_keypair(),
            case barrel_p2p_dist_auth:save_keypair(Dir, PubKey, PrivKey) of
                ok ->
                    Fp = barrel_p2p_dist_keys:fingerprint(PubKey),
                    logger:warning(
                        "barrel_p2p_rotate: identity rotated in ~s; "
                        "new fingerprint ~s. Peers running in "
                        "strict-trust mode must re-pin. Backup at ~s",
                        [Dir, hex(Fp), BackupDir]
                    ),
                    {ok, #{
                        cert_file => PubFile,
                        key_file => PrivFile,
                        backup_dir => BackupDir,
                        restart_required => false
                    }};
                Error ->
                    restore_backup(BackupDir, [PubFile, PrivFile]),
                    Error
            end;
        Error ->
            Error
    end.

%%====================================================================
%% Internal
%%====================================================================

%% Move every file in `Files' that currently exists into a fresh
%% timestamped subdirectory of `<Dir>/backups/'. Returns the backup
%% directory path (or `undefined' if no files existed). Files that
%% didn't exist are skipped without error.
backup_pair(Dir, Files) ->
    Existing = [F || F <- Files, filelib:is_regular(F)],
    case Existing of
        [] ->
            ok = filelib:ensure_dir(filename:join(Dir, "dummy")),
            {ok, undefined};
        _ ->
            BackupDir = filename:join([Dir, "backups", timestamp_dir()]),
            case filelib:ensure_dir(filename:join(BackupDir, "dummy")) of
                ok ->
                    case copy_all(Existing, BackupDir) of
                        ok -> {ok, BackupDir};
                        Error -> Error
                    end;
                {error, Reason} ->
                    {error, {backup_mkdir_failed, Reason}}
            end
    end.

copy_all([], _BackupDir) ->
    ok;
copy_all([F | Rest], BackupDir) ->
    Target = filename:join(BackupDir, filename:basename(F)),
    case file:copy(F, Target) of
        {ok, _} -> copy_all(Rest, BackupDir);
        {error, Reason} -> {error, {backup_copy_failed, F, Reason}}
    end.

%% Best-effort restore. Called when generation fails after the backup
%% completed but before the new file is in place. Crash-safety still
%% relies on the operator inspecting the backup dir.
restore_backup(undefined, _Files) ->
    ok;
restore_backup(BackupDir, Files) ->
    lists:foreach(
        fun(F) ->
            Source = filename:join(BackupDir, filename:basename(F)),
            case filelib:is_regular(Source) of
                true ->
                    _ = file:copy(Source, F),
                    ok;
                false ->
                    ok
            end
        end,
        Files
    ),
    ok.

timestamp_dir() ->
    {{Y, M, D}, {H, Mi, S}} = calendar:universal_time(),
    lists:flatten(
        io_lib:format(
            "~4..0w~2..0w~2..0wT~2..0w~2..0w~2..0wZ",
            [Y, M, D, H, Mi, S]
        )
    ).

hex(Bin) when is_binary(Bin) ->
    [io_lib:format("~2.16.0b", [B]) || <<B>> <= Bin].