#!/bin/sh
#
# nginx-ech-keygen - generate and rotate Encrypted Client Hello keys (RFC 9849)
#
# nginx reads ECH keys while it parses its configuration (ngx_ssl_ech_files),
# so a freshly generated key does nothing until nginx reloads. nginx also
# accepts several ssl_ech_file directives: the FIRST one is what it advertises
# in ECH retry-configs, and every other one stays loaded for decryption only.
# That is what makes rotation safe - a client that picked up an older
# ECHConfigList from a cached HTTPS DNS record still gets decrypted, because
# the key it encrypted to is still in the store.
#
# This script maintains that rolling set: newest key first, ECH_RETAIN keys
# kept, everything older shredded, and the include that lists them rewritten
# to match whatever is actually on disk.

set -eu
umask 077

CONFIG=${NGINX_ECH_CONFIG:-/etc/default/nginx-ech-rotate}

# Defaults, overridden by $CONFIG.
ECH_PUBLIC_NAME=""
ECH_RETAIN=3
ECH_DIR=/etc/nginx/ech
ECH_INCLUDE=/etc/nginx/conf.d/ech-keys.conf
ECH_GROUP=www-data
ECH_OPENSSL=openssl35
ECH_NGINX=nginx
ECH_SERVICE=nginx.service

# shellcheck source=/dev/null
[ -r "$CONFIG" ] && . "$CONFIG"

PROG=${0##*/}

die() {
    echo "$PROG: $*" >&2
    exit 1
}

usage() {
    cat <<EOF
Usage: $PROG <command>

  --init        Create the first ECH key plus the nginx include, then reload.
  --rotate      Create a new key, retire anything past ECH_RETAIN, rewrite the
                include and reload nginx.
  --print-dns   Print the current ECHConfigList in HTTPS-record form.
  --list        Show the keys currently in service, newest first.

Settings live in $CONFIG. Nothing works until ECH_PUBLIC_NAME is set there:
it is the cleartext "outer" SNI clients connect to, so it must resolve to this
server and be covered by a certificate you control.
EOF
}

# Key filenames embed a UTC timestamp, so a reverse lexical sort is
# newest-first. No stat(1) portability problem to worry about.
keys_newest_first() {
    [ -d "$ECH_DIR" ] || return 0
    find "$ECH_DIR" -maxdepth 1 -type f -name 'ech-*.pem' 2>/dev/null \
        | LC_ALL=C sort -r
}

require_openssl() {
    command -v "$ECH_OPENSSL" >/dev/null 2>&1 \
        || die "$ECH_OPENSSL not found. Install the openssl35 package."
    # The stock OpenSSL has no "ech" app; the DEfO backport in openssl35 does.
    "$ECH_OPENSSL" ech -help >/dev/null 2>&1 \
        || die "$ECH_OPENSSL has no 'ech' command - it predates the ECH backport."
}

generate_key() {
    [ -n "$ECH_PUBLIC_NAME" ] || die "ECH_PUBLIC_NAME is not set in $CONFIG"
    require_openssl

    # Second-resolution timestamps collide if a rotation runs twice in the
    # same second (a manual --rotate right after the timer's, say). Break the
    # tie with an "_NN" suffix rather than failing. '_' sorts above '.', so
    # ech-...Z_01.pem still sorts newer than ech-...Z.pem and the
    # newest-first ordering the retry-config depends on survives.
    _base="$ECH_DIR/ech-$(date -u '+%Y%m%dT%H%M%SZ')"
    _final="$_base.pem"
    _seq=0
    while [ -e "$_final" ]; do
        _seq=$((_seq + 1))
        [ "$_seq" -gt 99 ] && die "too many rotations within one second"
        _final=$(printf '%s_%02d.pem' "$_base" "$_seq")
    done

    # Build in a temporary file alongside the target, under umask 077, and
    # rename into place. nginx can never read a half-written key, and the
    # private key is never momentarily group- or world-readable.
    _tmp=$(mktemp "$ECH_DIR/.ech-XXXXXXXX") \
        || die "cannot create a temporary file in $ECH_DIR"

    if ! "$ECH_OPENSSL" ech -public_name "$ECH_PUBLIC_NAME" -out "$_tmp" \
         >/dev/null 2>&1; then
        rm -f "$_tmp"
        die "$ECH_OPENSSL ech failed for public_name '$ECH_PUBLIC_NAME'"
    fi

    chown "root:$ECH_GROUP" "$_tmp" 2>/dev/null || :
    chmod 0640 "$_tmp"
    mv -f "$_tmp" "$_final"
    command -v restorecon >/dev/null 2>&1 && restorecon "$_final" >/dev/null 2>&1 || :

    echo "$_final"
}

prune_keys() {
    keys_newest_first | awk -v keep="$ECH_RETAIN" 'NR > keep' \
    | while IFS= read -r _old; do
        # Past ECH_RETAIN generations no client should still be advertising
        # this key. Overwrite it where shred exists rather than just unlinking.
        if command -v shred >/dev/null 2>&1; then
            shred -u "$_old" 2>/dev/null || rm -f "$_old"
        else
            rm -f "$_old"
        fi
        echo "$PROG: retired $_old" >&2
    done
}

# Keep the previous include so a failed "nginx -t" can be rolled back. Without
# this, a rotation that trips over an unrelated config error would leave the
# include committed and the whole configuration unloadable, so the NEXT reload
# by anyone (logrotate, a certificate renewal) would fail too.
INCLUDE_BACKUP=""

write_include() {
    _dir=${ECH_INCLUDE%/*}
    [ -d "$_dir" ] || die "$_dir does not exist"

    if [ -f "$ECH_INCLUDE" ]; then
        INCLUDE_BACKUP="$ECH_INCLUDE.rollback"
        cp -p "$ECH_INCLUDE" "$INCLUDE_BACKUP"
    else
        INCLUDE_BACKUP="none"
    fi

    _tmp=$(mktemp "$_dir/.ech-keys-XXXXXXXX") \
        || die "cannot create a temporary file in $_dir"

    {
        echo "# Generated by $PROG. Do not edit - rotation overwrites this file."
        echo "#"
        echo "# nginx advertises the FIRST ssl_ech_file in ECH retry-configs and"
        echo "# keeps the rest loaded for decryption only, so clients still holding"
        echo "# an older ECHConfigList from a cached HTTPS record keep working."
        keys_newest_first | while IFS= read -r _key; do
            echo "ssl_ech_file $_key;"
        done
    } >"$_tmp"

    chmod 0644 "$_tmp"
    mv -f "$_tmp" "$ECH_INCLUDE"
    command -v restorecon >/dev/null 2>&1 && restorecon "$ECH_INCLUDE" >/dev/null 2>&1 || :
}

rollback_include() {
    case "$INCLUDE_BACKUP" in
        "")     : ;;
        none)   rm -f "$ECH_INCLUDE" ;;
        *)      mv -f "$INCLUDE_BACKUP" "$ECH_INCLUDE" ;;
    esac
}

reload_nginx() {
    if ! "$ECH_NGINX" -t >/dev/null 2>&1; then
        rollback_include
        die "'$ECH_NGINX -t' fails with the new $ECH_INCLUDE, so it was rolled back and nothing was reloaded. Run '$ECH_NGINX -t' to see why."
    fi

    # Committed. Drop the rollback copy. It is named .rollback rather than
    # .conf so nginx's include glob never picks it up even if we die here.
    case "$INCLUDE_BACKUP" in
        ""|none) : ;;
        *)       rm -f "$INCLUDE_BACKUP" ;;
    esac
    INCLUDE_BACKUP=""

    if command -v systemctl >/dev/null 2>&1; then
        if systemctl is-active --quiet "$ECH_SERVICE"; then
            systemctl reload "$ECH_SERVICE"
            return 0
        fi
    elif "$ECH_NGINX" -s reload >/dev/null 2>&1; then
        return 0
    fi

    echo "$PROG: nginx is not running; the new key applies at next start" >&2
}

cmd_init() {
    [ -n "$ECH_PUBLIC_NAME" ] || die "ECH_PUBLIC_NAME is not set in $CONFIG"

    if [ -n "$(keys_newest_first)" ]; then
        die "$ECH_DIR already holds keys; use --rotate"
    fi

    mkdir -p "$ECH_DIR"
    chown "root:$ECH_GROUP" "$ECH_DIR" 2>/dev/null || :
    chmod 0750 "$ECH_DIR"

    _key=$(generate_key)
    write_include
    reload_nginx

    echo "$PROG: created $_key"
    echo "$PROG: publish this in the HTTPS record for your ECH-enabled names:"
    cmd_print_dns
}

cmd_rotate() {
    [ -n "$(keys_newest_first)" ] || die "no keys in $ECH_DIR; run '$PROG --init' first"

    _key=$(generate_key)
    prune_keys
    write_include
    reload_nginx

    echo "$PROG: rotated in $_key"
    echo "$PROG: the HTTPS record value has changed; republish it:"
    cmd_print_dns
}

cmd_print_dns() {
    _newest=$(keys_newest_first | head -n 1)
    [ -n "$_newest" ] || die "no keys in $ECH_DIR; run '$PROG --init' first"

    # Only the public ECHCONFIG block is read. The PRIVATE KEY block in the
    # same file is never touched here, so this is safe to run anywhere and
    # safe to log.
    _b64=$(sed -n '/-----BEGIN ECHCONFIG-----/,/-----END ECHCONFIG-----/p' "$_newest" \
           | sed -e '/-----/d' | tr -d '\n')
    [ -n "$_b64" ] || die "no ECHCONFIG block in $_newest"

    printf 'ech="%s"\n' "$_b64"
}

cmd_list() {
    _keys=$(keys_newest_first)
    [ -n "$_keys" ] || die "no keys in $ECH_DIR; run '$PROG --init' first"

    echo "$_keys" | awk '{ print (NR == 1 ? "advertised  " : "decrypt-only") "  " $0 }'
}

case "${1:---help}" in
    --init)      cmd_init ;;
    --rotate)    cmd_rotate ;;
    --print-dns) cmd_print_dns ;;
    --list)      cmd_list ;;
    --help|-h)   usage ;;
    *)           usage >&2; exit 2 ;;
esac
