#!/usr/bin/env bash
# cyphers-crypto-mode — the only supported way to change crypto mode on a target.
#
#   cyphers-crypto-mode doctor         report the current posture
#   cyphers-crypto-mode enable-fips    activate strict FIPS mode
#   cyphers-crypto-mode disable-fips   return to standard mode
#
# Why this exists rather than "edit installation.toml":
#
#   Activation is not a configuration change. The validated module's integrity
#   check is computed over the module AS INSTALLED HERE, so a target-local
#   fipsmodule.cnf has to be generated by this machine, against this copy of
#   fips.so, before strict mode can start. A build-host artifact would either be
#   wrong or would paper over a real build/target difference.
#
#   And it has to be all-or-nothing. Strict mode needs the installation file,
#   the OpenSSL config and the module config to agree; a half-applied change is
#   worse than either mode, because the process either refuses to start or
#   starts in a posture nobody chose. So the new files are staged, validated in
#   a FRESH process, and only then renamed into place — with the previous set
#   kept as one last-good unit that is restored together on any failure.
#
# Runs as root on the target. CYPHERS_ROOT re-roots every path for testing.
set -uo pipefail

ROOT="${CYPHERS_ROOT:-}"
SERVICE_CMD="${CYPHERS_SERVICE_CMD:-systemctl}"

OPENSSL_BASE="${ROOT}/opt/cyphers/openssl"
INSTALLATION="${ROOT}/etc/cyphers/installation.toml"
OPENSSL_CONF="${ROOT}/etc/cyphers/openssl.cnf"
FIPS_STATE_DIR="${ROOT}/var/lib/cyphers/fips"
# Resolved the same way the Hub does: inside the runtime directory.
MANIFEST="${OPENSSL_BASE}/3.5.7/crypto-manifest.json"
OE_FILE="${OPENSSL_BASE}/operating-environments.json"
LAST_GOOD="${ROOT}/var/lib/cyphers/crypto-last-good"
HUB_BIN="${ROOT}/usr/local/bin/cyphers-hub"

die() { printf 'cyphers-crypto-mode: %s\n' "$*" >&2; exit 1; }
info() { printf '%s\n' "$*"; }

need_file() { [[ -f "$1" ]] || die "missing required file: ${1#"$ROOT"}"; }

runtime_dir() {
  local d
  d="$(grep -E '^[[:space:]]*openssl_runtime_dir' "$INSTALLATION" \
        | head -1 | sed -E 's/.*=[[:space:]]*"([^"]+)".*/\1/')"
  [[ -n "$d" ]] || die "installation.toml does not name openssl_runtime_dir"
  printf '%s%s\n' "$ROOT" "$d"
}

provider_module() {
  local p
  p="$(grep -E '^[[:space:]]*provider_module' "$INSTALLATION" \
        | head -1 | sed -E 's/.*=[[:space:]]*"([^"]+)".*/\1/')"
  [[ -n "$p" ]] || die "installation.toml does not name provider_module"
  printf '%s%s\n' "$ROOT" "$p"
}

current_mode() {
  if grep -Eq '^[[:space:]]*fips_enabled[[:space:]]*=[[:space:]]*true' "$INSTALLATION"; then
    printf 'fips\n'
  else
    printf 'standard\n'
  fi
}

# --- verification ----------------------------------------------------------

# Every artifact the manifest names must still hash to what it claims. Run
# before anything is touched, so a tampered install is refused rather than
# activated.
verify_manifest() {
  need_file "$MANIFEST"
  python3 - "$MANIFEST" <<'PY'
import hashlib, json, pathlib, sys
manifest = pathlib.Path(sys.argv[1])
# Paths are relative to the runtime directory — the manifest's own directory —
# matching how hub/src/crypto/manifest.rs resolves them.
runtime_dir = manifest.parent
try:
    doc = json.load(open(manifest))
except Exception as exc:
    print(f"crypto manifest is not valid JSON: {exc}", file=sys.stderr)
    sys.exit(1)
bad = []
for art in doc.get("openssl_runtime", {}).get("artifacts", []):
    path = runtime_dir / art["path"]
    try:
        got = hashlib.sha256(path.read_bytes()).hexdigest()
    except FileNotFoundError:
        bad.append(f"missing {art['path']}")
        continue
    if got != art["sha256"]:
        bad.append(f"digest mismatch for {art['path']}")
prov = doc.get("fips_provider")
if prov:
    mod = runtime_dir / prov["module_path"]
    try:
        if hashlib.sha256(mod.read_bytes()).hexdigest() != prov["sha256"]:
            bad.append("digest mismatch for the provider module")
    except FileNotFoundError:
        bad.append(f"missing provider module {prov['module_path']}")
if bad:
    print("crypto manifest integrity check failed:", file=sys.stderr)
    for b in bad:
        print(f"  {b}", file=sys.stderr)
    sys.exit(1)
PY
}

# The reviewed allowlist. It ships EMPTY, which means strict mode cannot be
# activated anywhere — deliberately. Admitting an environment is the output of
# an independent compliance review, and this command is not that review.
verify_operating_environment() {
  need_file "$OE_FILE"
  local host_arch host_os
  host_arch="$(uname -m)"
  host_os="$(uname -s | tr '[:upper:]' '[:lower:]')"

  python3 - "$OE_FILE" "$host_os" "$host_arch" <<'PY'
import json, sys
path, host_os, host_arch = sys.argv[1], sys.argv[2], sys.argv[3]
doc = json.load(open(path))
admitted = doc.get("operating_environments", [])
if not admitted:
    print("refusing: the operating-environment allowlist is empty, so no "
          "environment is admitted for validated operation.", file=sys.stderr)
    print("  This is the shipped state. Populating it is the output of an "
          "independent compliance review, not of this command.", file=sys.stderr)
    sys.exit(1)
for oe in admitted:
    if oe.get("arch") != host_arch:
        continue
    if oe.get("os") not in (host_os, "linux", "any"):
        continue
    sys.exit(0)
print(f"refusing: this host ({host_os}/{host_arch}) is not in the admitted "
      f"operating-environment allowlist.", file=sys.stderr)
sys.exit(1)
PY
}

# --- last-good unit --------------------------------------------------------
# The tuple that must move together: authority, OpenSSL config, module config.

save_last_good() {
  rm -rf "$LAST_GOOD"
  mkdir -p "$LAST_GOOD"
  cp -a "$INSTALLATION" "$LAST_GOOD/installation.toml"
  [[ -f "$OPENSSL_CONF" ]] && cp -a "$OPENSSL_CONF" "$LAST_GOOD/openssl.cnf"
  if [[ -f "${FIPS_STATE_DIR}/fipsmodule.cnf" ]]; then
    cp -a "${FIPS_STATE_DIR}/fipsmodule.cnf" "$LAST_GOOD/fipsmodule.cnf"
  fi
}

restore_last_good() {
  [[ -d "$LAST_GOOD" ]] || return 0
  info "restoring the last-good crypto configuration"
  cp -a "$LAST_GOOD/installation.toml" "$INSTALLATION"
  if [[ -f "$LAST_GOOD/openssl.cnf" ]]; then
    cp -a "$LAST_GOOD/openssl.cnf" "$OPENSSL_CONF"
  fi
  if [[ -f "$LAST_GOOD/fipsmodule.cnf" ]]; then
    cp -a "$LAST_GOOD/fipsmodule.cnf" "${FIPS_STATE_DIR}/fipsmodule.cnf"
  else
    rm -f "${FIPS_STATE_DIR}/fipsmodule.cnf"
  fi
}

# Stage, fsync, rename. Same filesystem so the rename is atomic; a crash leaves
# either the old file or the new one, never a truncated mix.
atomic_install() { # SRC DEST -> nonzero on failure
  local src="$1" dest="$2" staged="${2}.staged.$$"
  # Test-only hook: lets the suite induce a failure at the last cutover step,
  # which is the only place the rollback path does any work. Without a way to
  # reach it, that path cannot be exercised at all.
  if [[ -n "${CYPHERS_TEST_BLOCK_CUTOVER:-}" && "$dest" == *"installation.toml" ]]; then
    return 1
  fi
  cp -a "$src" "$staged" || return 1
  sync "$staged" 2>/dev/null || true
  mv -f "$staged" "$dest" || { rm -f "$staged"; return 1; }
}

set_fips_enabled() { # true|false  FILE
  local value="$1" file="$2"
  sed -E "s/^([[:space:]]*fips_enabled[[:space:]]*=[[:space:]]*).*/\1${value}/" \
    "$file" > "${file}.tmp.$$"
  mv -f "${file}.tmp.$$" "$file"
}

# --- subcommands -----------------------------------------------------------

cmd_doctor() {
  need_file "$INSTALLATION"
  info "crypto mode:      $(current_mode)"
  info "installation:     ${INSTALLATION#"$ROOT"}"
  info "runtime dir:      $(runtime_dir | sed "s|^${ROOT}||")"
  info "provider module:  $(provider_module | sed "s|^${ROOT}||")"

  if verify_manifest 2>"$LAST_GOOD.doctor.err"; then
    info "manifest:         ok (all artifact digests match)"
  else
    info "manifest:         FAILED"
    sed 's/^/  /' "$LAST_GOOD.doctor.err" >&2
  fi
  rm -f "$LAST_GOOD.doctor.err"

  if verify_operating_environment 2>/dev/null; then
    info "operating env:    admitted for validated operation"
  else
    info "operating env:    NOT admitted — strict FIPS mode cannot be activated"
  fi

  if [[ -f "${FIPS_STATE_DIR}/fipsmodule.cnf" ]]; then
    info "module config:    present (generated on this host)"
  else
    info "module config:    absent (generated by enable-fips)"
  fi
  return 0
}

cmd_enable_fips() {
  need_file "$INSTALLATION"
  [[ "$(current_mode)" == "fips" ]] && { info "already in fips mode"; return 0; }

  # Order matters: refuse on the cheapest, most fundamental grounds first, and
  # before touching anything. An unreviewed platform must never load the module
  # at all, and a tampered install must never be activated.
  verify_manifest || die "refusing to activate on an install that fails its own manifest"
  verify_operating_environment || exit 1

  local rt_dir mod_path openssl_cli
  rt_dir="$(runtime_dir)"
  mod_path="$(provider_module)"
  openssl_cli="${rt_dir}/bin/openssl"
  need_file "$openssl_cli"
  need_file "$mod_path"

  save_last_good
  mkdir -p "$FIPS_STATE_DIR"

  local staged_module="${FIPS_STATE_DIR}/fipsmodule.cnf.candidate.$$"
  local staged_conf="${OPENSSL_CONF}.candidate.$$"
  local staged_inst="${INSTALLATION}.candidate.$$"
  # Cleaned up explicitly rather than with `trap ... RETURN`. A RETURN trap set
  # inside a function is GLOBAL in bash without `set -o functrace`, so it fires
  # when the next *inner* function returns and deletes the staged files
  # mid-activation.
  clear_staged() { rm -f "$staged_module" "$staged_conf" "$staged_inst"; }

  # Generated HERE, against THIS module. Never copied from the build host.
  info "generating the module configuration for this host"
  if ! "$openssl_cli" fipsinstall -module "$mod_path" -out "$staged_module" \
        -provider_name fips -pedantic >/dev/null 2>&1; then
    clear_staged
    restore_last_good
    die "fipsinstall failed; the install is unchanged"
  fi

  # Render the FIPS config against the path the module config will occupy.
  sed "s|@FIPS_MODULE_CONFIG@|/var/lib/cyphers/fips/fipsmodule.cnf|g" \
    "${OPENSSL_BASE}/openssl-fips.cnf.in" > "$staged_conf"

  cp -a "$INSTALLATION" "$staged_inst"
  set_fips_enabled true "$staged_inst"

  # Prove the candidate in a FRESH process before cutting over. An in-process
  # check would be testing the configuration this process already loaded, which
  # is exactly the thing that cannot be re-read.
  info "validating the candidate posture in a fresh process"
  if [[ -x "$HUB_BIN" ]]; then
    if ! OPENSSL_CONF="$staged_conf" "$HUB_BIN" doctor \
          --installation-config "$staged_inst" >/dev/null 2>&1; then
      clear_staged
      restore_last_good
      die "the candidate FIPS posture failed validation; the install is unchanged"
    fi
  else
    info "  (no cyphers-hub on PATH — skipping the fresh-process check)"
  fi

  # Cut over. Module config first so the rendered config never points at a
  # missing include, then the config, then the authority last.
  #
  # This is the ONLY window where restore_last_good matters: everything above
  # this point is staged, so a failure there leaves the install untouched. Here
  # a failure can leave two of three files switched, which is precisely the
  # mixed state that is worse than either mode — so any failure rolls the whole
  # tuple back.
  if ! atomic_install "$staged_module" "${FIPS_STATE_DIR}/fipsmodule.cnf" \
     || ! atomic_install "$staged_conf" "$OPENSSL_CONF" \
     || ! atomic_install "$staged_inst" "$INSTALLATION"; then
    restore_last_good
    clear_staged
    die "cutover failed partway; the previous configuration was restored"
  fi

  clear_staged
  info "restarting the Hub"
  "$SERVICE_CMD" restart cyphers-hub >/dev/null 2>&1 || true
  info "strict FIPS mode activated"
}

cmd_disable_fips() {
  need_file "$INSTALLATION"
  [[ "$(current_mode)" == "standard" ]] && { info "already in standard mode"; return 0; }

  save_last_good

  local staged_conf="${OPENSSL_CONF}.candidate.$$"
  local staged_inst="${INSTALLATION}.candidate.$$"
  clear_staged() { rm -f "$staged_conf" "$staged_inst"; }

  cp -a "${OPENSSL_BASE}/openssl-standard.cnf" "$staged_conf"
  cp -a "$INSTALLATION" "$staged_inst"
  set_fips_enabled false "$staged_inst"

  # Authority first on the way down: if anything interrupts this, a standard
  # authority beside a FIPS config fails closed at startup, whereas a FIPS
  # authority beside a standard config would claim a posture it does not have.
  if ! atomic_install "$staged_inst" "$INSTALLATION" \
     || ! atomic_install "$staged_conf" "$OPENSSL_CONF"; then
    restore_last_good
    clear_staged
    die "cutover failed partway; the previous configuration was restored"
  fi

  # The module config is left in place deliberately. It is not secret, it is
  # only meaningful in FIPS mode, and keeping it makes re-enabling cheap while
  # recording that validated mode was once active here.
  clear_staged
  info "restarting the Hub"
  "$SERVICE_CMD" restart cyphers-hub >/dev/null 2>&1 || true
  info "standard mode restored"
}

case "${1:-}" in
  doctor)       cmd_doctor ;;
  enable-fips)  cmd_enable_fips ;;
  disable-fips) cmd_disable_fips ;;
  *)
    cat >&2 <<USAGE
usage: cyphers-crypto-mode <doctor|enable-fips|disable-fips>

  doctor         report the current crypto posture and whether strict FIPS
                 mode could be activated on this host
  enable-fips    generate the module configuration on this host, validate it
                 in a fresh process, and cut over atomically
  disable-fips   return to standard mode atomically

Strict FIPS mode requires an operating environment admitted by an independent
compliance review. The allowlist ships empty, so enable-fips refuses until one
is admitted.
USAGE
    exit 2
    ;;
esac
