#!/bin/sh
# Namebase trust helper — runs as root, through pkexec, for the two places a
# browser reads a certificate from that a user cannot write:
#
#   * the distribution's trust anchors — WebKitGTK and Qt browsers, curl, wget
#   * the Chromium-family enterprise policy — Chromium, Chrome, Brave, Vivaldi,
#     Edge and Opera stopped reading the NSS database for locally-added
#     authorities, and read one only from a root-owned policy file
#
# Firefox reads a CA from the same kind of enterprise policy Chromium does,
# but as ONE policies.json rather than a directory, so this script has to
# merge our entry into it rather than write it whole.
#
#   namebase-trust install < ca.pem
#   namebase-trust remove
#
# Shipped in the package rather than generated at run time on purpose: polkit
# identifies what it is authorising by PATH, so the program it runs has to be
# one root owns and the user cannot rewrite between the prompt and the run.
set -eu

NAME="namebase-local-ca"
ACTION="${1:-install}"

# Every path below is prefixed with this. Empty in production; a temporary
# directory under test, which is the only way any of this is exercised without
# being root.
ROOT="${NAMEBASE_TRUST_ROOT:-}"

if [ -z "$ROOT" ] && [ "$(id -u)" != 0 ]; then
    echo "run this with sudo" >&2
    exit 1
fi

# Where the certificate lives for anything that needs to NAME it — the Firefox
# policy does. Fixed, so it does not depend on which anchor directory exists,
# and under /etc/firefox because that is the ONLY tree the Ubuntu Firefox snap
# can read: it plugs system-files as `etc-firefox`, and nothing else outside
# its own root exists in its mount namespace. Verified on Ubuntu with the snap
# — /usr/local/share/namebase read back as "No such file or directory" from
# inside the sandbox, so Certificates.Install silently imported nothing while
# browser.policies.applied was still true. The failure is invisible from the
# host, where the file plainly exists.
FIXED_DIR="$ROOT/etc/firefox"
FIXED_CERT="$FIXED_DIR/$NAME.crt"

# What THIS helper created, so `remove` takes back exactly that and nothing
# else. /etc/firefox, /etc/firefox/policies and policies.json are all standard
# documented locations an administrator may already own, and none of them can
# be attributed by looking at it: an empty directory does not say who made it,
# and a policies.json holding nothing but an empty Install list is our own
# scaffolding on one machine and somebody's placeholder on the next. rmdir
# refusing a non-empty directory is a guard against destroying CONTENTS; it
# was never an answer to whose directory it is.
CREATED_MARK="$FIXED_DIR/.namebase-created"

# Record one thing as ours, once. Install runs again on every certificate
# rotation and creates nothing the second time, so the first run's record has
# to survive the ones after it — this appends, and never rewrites.
mark_created() {
    if [ -f "$CREATED_MARK" ] && grep -qx "$1" "$CREATED_MARK" 2>/dev/null; then
        return 0
    fi
    printf '%s\n' "$1" >> "$CREATED_MARK" 2>/dev/null || true
    chmod 644 "$CREATED_MARK" 2>/dev/null || true
    return 0
}

# Whether this helper created it, according to the record.
we_created() {
    [ -f "$CREATED_MARK" ] || return 1
    grep -qx "$1" "$CREATED_MARK" 2>/dev/null
}

# Whether there IS a record. The distinction matters: a marker that exists and
# does not name policies.json means we know we did not create it, and it stays.
# NO marker at all means the install predates this file entirely, and we know
# nothing — there the old rule has to apply, because the alternative is leaving
# an empty enterprise policy on every machine that has been running Namebase
# longest, which is the residue this whole route exists to avoid. Observed:
# a machine installed on Sep 1 kept /etc/firefox/policies/policies.json after
# `remove`, and Firefox goes on saying "managed by your organisation" for it.
have_record() {
    [ -f "$CREATED_MARK" ]
}

# dir:command — the command rebuilds the bundle from the directory. Detected by
# the directory being there, not by parsing /etc/os-release, so a derivative
# distribution answers correctly without being on any list.
ANCHOR_LAYOUTS="/usr/local/share/ca-certificates:update-ca-certificates
/etc/pki/ca-trust/source/anchors:update-ca-trust
/etc/ca-certificates/trust-source/anchors:trust extract-compat
/etc/pki/trust/anchors:update-ca-certificates"

# A snap's policies live under its own root, not /etc: verified against
# Chromium 151, where /etc/chromium/policies/managed has no effect at all.
POLICY_ROOTS="/etc/chromium
/etc/chromium-browser
/etc/opt/chrome
/etc/opt/edge
/etc/brave
/etc/vivaldi
/etc/opera
/var/snap/chromium/current
/var/snap/chromium-browser/current
/var/snap/brave/current
/var/snap/opera/current
/var/snap/vivaldi/current"

# Two spellings are accepted for each verb: pkexec runs this file with a
# bare word (run_privileged in ca.rs calls `namebase-trust install` and
# `namebase-trust remove`), while the standalone fallback script is something
# a human types by hand, with a flag, exactly as its own header instructs
# (`sudo trust-linux.sh --remove`). Normalized to the bare word here so every
# check below only has to know one spelling.
case "$ACTION" in
    install|--install)
        ACTION=install
        # From the environment when the caller has already inlined it — that is
        # how the generated fallback delivers it, and it keeps this file the
        # only implementation. Otherwise stdin, which is how pkexec delivers it
        # to the packaged copy: a path would be a thing that can point
        # somewhere else by the time root opens it.
        PEM="${PEM:-$(cat)}"
        case "$PEM" in
            *"BEGIN CERTIFICATE"*) ;;
            *) echo "namebase-trust: no PEM certificate given" >&2; exit 1 ;;
        esac
        # A PEM certificate IS the DER in base64 between two markers, which is
        # exactly what Chrome's policy wants — so nothing here needs openssl.
        # First block only: a chain on stdin must not become a trusted root.
        DER64=$(printf '%s\n' "$PEM" \
            | awk '/BEGIN CERTIFICATE/{f=1;next} /END CERTIFICATE/{exit} f' \
            | tr -d '\n\r')
        [ -n "$DER64" ] || { echo "namebase-trust: empty certificate" >&2; exit 1; }
        ;;
    remove|--remove) ACTION=remove ;;
    *) echo "usage: namebase-trust [install|--install|remove|--remove]" >&2; exit 2 ;;
esac

# This runs FIRST, under `set -eu`, and now writes into /etc/firefox — the one
# directory a dangling symlink or a file in the way can make unusable. Every
# step of it is therefore guarded: an unusable /etc/firefox costs Firefox and
# nothing else, where an unguarded failure here would take the anchors and the
# Chromium policies down with it and report a failed install that had not even
# been attempted. FIXED_CERT_OK carries the verdict to firefox_policy, which
# must never name a certificate that was not written.
FIXED_CERT_OK=no
if [ "$ACTION" = install ]; then
    # Asked BEFORE mkdir, because afterwards there is nothing left to ask.
    HAD_FIXED_DIR=yes
    [ -d "$FIXED_DIR" ] || HAD_FIXED_DIR=no
    # Subshell, per the note in firefox_policy: a failing `>` reports before a
    # 2>/dev/null on the same command can take effect.
    if mkdir -p "$FIXED_DIR" 2>/dev/null &&
        (printf '%s\n' "$PEM" > "$FIXED_CERT") 2>/dev/null; then
        chmod 644 "$FIXED_CERT" 2>/dev/null || true
        # Created whether or not anything goes in it: an absent record has to
        # mean "install predates this", not "created nothing".
        : >> "$CREATED_MARK" 2>/dev/null || true
        chmod 644 "$CREATED_MARK" 2>/dev/null || true
        if [ "$HAD_FIXED_DIR" = no ]; then mark_created firefox; fi
        FIXED_CERT_OK=yes
        echo "  cert:    $FIXED_CERT"
    else
        echo "  firefox: $FIXED_CERT could not be written." >&2
        echo "           Firefox will not trust the CA. Every other browser is" >&2
        echo "           done; put the certificate there and add it to" >&2
        echo "           policies.Certificates.Install by hand to finish it." >&2
    fi
else
    # The certificate only. The directories come off at the very end, after
    # firefox_policy has taken policies.json out of the one below this — and
    # only the ones this helper recorded as its own.
    rm -f "$FIXED_CERT"
fi

echo "$ANCHOR_LAYOUTS" | while IFS= read -r layout; do
    dir=${layout%%:*}
    cmd=${layout#*:}
    [ -d "$ROOT$dir" ] || continue
    if [ "$ACTION" = remove ]; then
        rm -f "$ROOT$dir/$NAME.crt"
    else
        printf '%s\n' "$PEM" > "$ROOT$dir/$NAME.crt"
        chmod 644 "$ROOT$dir/$NAME.crt"
    fi
    $cmd >/dev/null 2>&1 || true
    echo "  anchors: $dir"
done

echo "$POLICY_ROOTS" | while IFS= read -r root; do
    [ -d "$ROOT$root" ] || continue
    dir="$ROOT$root/policies/managed"
    if [ "$ACTION" = remove ]; then
        rm -f "$dir/$NAME.json"
    else
        mkdir -p "$dir"
        printf '{"CACertificates":["%s"]}\n' "$DER64" > "$dir/$NAME.json"
        chmod 644 "$dir/$NAME.json"
    fi
    echo "  policy:  $dir"
done

# Firefox takes a CA the same way Chromium does, which is what makes certutil
# unnecessary. It reads ONE policies.json — including the snap, whose read-only
# filesystem makes /etc/firefox/policies the documented location — so this can
# never be a blind write: an administrator's other keys, and any other
# program's Install entries, have to survive both install and removal.
FIREFOX_POLICY_DIR="$ROOT/etc/firefox/policies"
FIREFOX_POLICY="$FIREFOX_POLICY_DIR/policies.json"

# What every Firefox failure below says instead of failing: the file is left
# exactly as it was, and the administrator is told the one line to add or take
# out by hand. Same shape as the Python half's leave_alone.
firefox_by_hand() {
    echo "  firefox: $1" >&2
    if [ "$ACTION" = install ]; then
        echo "           Add \"$FIXED_CERT\" to policies.Certificates.Install by hand." >&2
    else
        echo "           Remove \"$FIXED_CERT\" from policies.Certificates.Install by hand." >&2
    fi
    return 0
}

# Every exit below is 0 on purpose, this shell half included: it runs under
# `set -eu` after the anchors and Chromium policies have already been written,
# and a non-zero exit here would abort the script mid-way and report a failure
# for work that already succeeded. So nothing here is left unguarded — not the
# mkdir (a dangling /etc/firefox symlink fails it), not the create (a
# read-only /etc does), and not python3. A file this script cannot safely
# handle — not valid JSON, not an object, "policies"/"policies.Certificates"
# present but not an object, or a directory root cannot write — is left
# untouched and reported on stderr, never clobbered and never crashed into.
firefox_policy() {
    if [ "$ACTION" = install ]; then
        # Nothing to point a policy at: the reason was already reported where
        # the write failed, and a policy naming a missing file is worse than
        # no policy — Firefox reports nothing, and `--remove` would later find
        # our entry pointing into space.
        [ "$FIXED_CERT_OK" = yes ] || return 0
        # Created whether or not Firefox is installed: the path is
        # distribution-standard, so writing it is what makes a Firefox
        # installed later trust the CA without a second trip through pkexec.
        # `--remove` takes the file and the directories back off again — the
        # ones recorded here as this install's own, and only those.
        had_policy_dir=yes
        [ -d "$FIREFOX_POLICY_DIR" ] || had_policy_dir=no
        if ! mkdir -p "$FIREFOX_POLICY_DIR" 2>/dev/null; then
            firefox_by_hand "$FIREFOX_POLICY_DIR could not be created."
            return 0
        fi
        if [ "$had_policy_dir" = no ]; then mark_created policies; fi
        if [ ! -f "$FIREFOX_POLICY" ]; then
            # The subshell is what keeps the failure quiet: redirections are
            # applied left to right, so `> "$FIREFOX_POLICY"` fails before a
            # 2>/dev/null on the same command can take effect, and the shell's
            # own "cannot create" leaks out past the message below.
            if ! (printf '{\n  "policies": {\n    "Certificates": {\n      "Install": ["%s"]\n    }\n  }\n}\n' \
                    "$FIXED_CERT" > "$FIREFOX_POLICY") 2>/dev/null; then
                firefox_by_hand "$FIREFOX_POLICY could not be written."
                return 0
            fi
            chmod 644 "$FIREFOX_POLICY" 2>/dev/null || true
            mark_created policies.json
            echo "  firefox: $FIREFOX_POLICY"
            return 0
        fi
    else
        [ -f "$FIREFOX_POLICY" ] || return 0
    fi

    # Merging JSON needs a JSON parser. python3 is on every desktop Linux this
    # ships to; where it is not, the file is left ALONE and the user is told
    # what to add. Clobbering an administrator's policies to save a dependency
    # is not a trade worth making.
    if ! command -v python3 >/dev/null 2>&1; then
        firefox_by_hand "$FIREFOX_POLICY exists and python3 is missing."
        return 0
    fi

    # yes / no / unknown — and only a definite "no" protects the file.
    if have_record; then
        OURS=no
        if we_created policies.json; then OURS=yes; fi
    else
        OURS=unknown
    fi
    if ! python3 - "$FIREFOX_POLICY" "$FIXED_CERT" "$ACTION" "$OURS" <<'PY'
import copy, json, os, sys, tempfile

path, cert, action, ours = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]

# A file holding nothing but the keys our own install creates, once our entry
# has been taken out of it. Removal DELETES such a file rather than leaving it:
# any policies.json at all puts Firefox into enterprise mode — "your browser is
# being managed by your organisation", forever — and leaving that behind on a
# machine that had no policy file before, after the user turned resolving off,
# is the residue this whole route exists to avoid.
#
# But this shape is only OURS when we made it. An administrator who keeps an
# empty Certificates.Install as a placeholder ends up at exactly the same three
# keys after our entry comes out, and deleting their file would be destroying
# somebody's configuration to tidy away our own. So the shape is not the whole
# question — `ours` is, as recorded by the install that created the file.
#
# Three values, not two. "no" is the only one that protects the file: it means
# a record exists and does not name policies.json. "unknown" means there is no
# record at all — an install from before the record existed — and there the
# shape is the best evidence there is, which is what it always used to be.
SCAFFOLDING = {"policies": {"Certificates": {"Install": []}}}


def leave_alone(problem):
    sys.stderr.write("  firefox: %s %s\n" % (path, problem))
    if action == "install":
        sys.stderr.write("           Add \"%s\" to policies.Certificates.Install by hand.\n" % cert)
    else:
        sys.stderr.write("           Remove \"%s\" from policies.Certificates.Install by hand.\n" % cert)
    sys.exit(0)


try:
    with open(path, encoding="utf-8") as f:
        original = json.load(f)
except (ValueError, OSError):
    leave_alone("could not be parsed as JSON.")

if not isinstance(original, dict):
    leave_alone("does not contain a JSON object.")

# Mutated in place below; `original` stays as loaded so the two can be compared
# before anything is written.
doc = copy.deepcopy(original)

policies = doc.get("policies", {})
if not isinstance(policies, dict):
    leave_alone("its \"policies\" key is not an object.")

certs = policies.get("Certificates", {})
if not isinstance(certs, dict):
    leave_alone("its \"policies.Certificates\" key is not an object.")

install = certs.get("Install", [])

if action == "install":
    if not isinstance(install, list):
        install = []
    if cert not in install:
        install.append(cert)
    certs["Install"] = install
    policies["Certificates"] = certs
    doc["policies"] = policies
elif isinstance(install, list) and cert in install:
    certs["Install"] = [c for c in install if c != cert]
# Removal adds nothing. A file that never held our entry is not a file a
# REMOVAL gets to write: not to scaffold "policies"/"Certificates"/"Install"
# into it, not to re-escape its non-ASCII, not to reindent it.

if action == "remove" and doc == SCAFFOLDING and ours != "no":
    try:
        os.unlink(path)
    except OSError:
        leave_alone("could not be removed.")
    # The directories are the caller's to take back, and it knows which of
    # them it made. A file left behind identical to the one found is the
    # outcome when `ours` is no: `doc == original` below, so nothing is
    # written at all.
    print("  firefox: %s (removed)" % path)
    sys.exit(0)

if doc != original:
    # Written as a sibling and renamed over the target, rather than truncating
    # the administrator's file and hoping the write finishes: a crash or a full
    # disk mid-write would otherwise leave a policies.json that is neither
    # theirs nor ours, and destroying nothing is this route's whole point.
    # ensure_ascii=False so a "café" in someone else's value comes back out as
    # it went in, rather than re-escaped by a pass that had nothing to say
    # about it.
    directory = os.path.dirname(path) or "."
    try:
        mode = os.stat(path).st_mode & 0o7777
    except OSError:
        mode = 0o644
    tmp = None
    try:
        fd, tmp = tempfile.mkstemp(dir=directory, prefix=".policies.json.")
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump(doc, f, indent=2, ensure_ascii=False)
            f.write("\n")
        os.chmod(tmp, mode)
        os.replace(tmp, path)
    except OSError:
        if tmp is not None:
            try:
                os.unlink(tmp)
            except OSError:
                pass
        leave_alone("could not be rewritten.")

print("  firefox: %s" % path)
PY
    then
        firefox_by_hand "$FIREFOX_POLICY could not be updated."
    fi
    return 0
}

firefox_policy

# The directories, last of all: policies.json had to come out of the lower one
# before either could be empty. Only the ones recorded at install are taken —
# rmdir refusing a non-empty directory protects an administrator's CONTENTS,
# and never answered whose directory it was.
if [ "$ACTION" = remove ] && [ -f "$CREATED_MARK" ]; then
    if we_created policies; then
        rmdir "$FIREFOX_POLICY_DIR" 2>/dev/null || true
    fi
    # The marker is the last thing of ours in $FIXED_DIR, so it has to go
    # before that can be empty — and it goes either way, because otherwise it
    # is our bookkeeping left sitting in somebody else's directory.
    CREATED_FIXED=no
    if we_created firefox; then CREATED_FIXED=yes; fi
    rm -f "$CREATED_MARK"
    if [ "$CREATED_FIXED" = yes ]; then
        rmdir "$FIXED_DIR" 2>/dev/null || true
    fi
fi

exit 0
