Wren Linux

wren-linux / lib / browser-sandbox.sh

# shellcheck shell=bash
# lib/browser-sandbox.sh: what extras/tor-browser.sh and extras/mullvad-browser-sandbox.sh
# share. Each browser runs as a user of its own: no password, no login, its own home, and none
# of your files. bin/browser-sandbox starts it, bin/browser-session is what your user may run as
# that user, and data/NAME-firewall.nft keeps it off this computer and your network.
# Sourced after lib.sh.

BROWSER_CMD=/usr/local/bin/browser-sandbox
BROWSER_SESSION=/usr/local/libexec/browser-session
BROWSER_FILES=/var/lib/wren-browsers
BROWSER_NAMES=(tor-browser tor-browser-unsafe mullvad-browser)

# as_browser NAME COMMAND...: COMMAND as that user, from a folder it can enter. Everything in
# its home is done this way: root writing in a folder a hacked browser can change could be
# led through a link to any file on this computer.
as_browser() {
    local name=$1
    shift
    (cd / && sudo -u "$name" -- "$@")
}

# browser_user NAME COMMENT: a system user with no password or login, whose home only it can
# enter. In /home: Tor Browser's AppArmor profile only attaches to a browser under it.
browser_user() {
    local name=$1 comment=$2 home=/home/$1
    if id "$name" >/dev/null 2>&1; then
        [ "$(id -u "$name")" -lt 1000 ] ||
            die "A regular user account called $name already exists; this needs a system user. Rename or remove it first."
    else
        sudo useradd --system --user-group --create-home --home-dir "$home" \
            --shell /usr/sbin/nologin --comment "$comment" "$name"
    fi
    sudo usermod --lock --shell /usr/sbin/nologin "$name"
    # Kept from before a --remove, it may have had another user number. Nothing runs as that
    # user now (--remove ended it all), so this can't be led astray. -h: a link is changed itself.
    if [ "$(sudo stat -c %u "$home")" != "$(id -u "$name")" ]; then
        sudo chown -Rh "$name:$name" "$home"
    fi
    sudo chmod 0700 "$home"
    # cage's Wayland backend opens the GPU's render node even with the software renderer.
    # render, not video: no modesetting.
    if getent group render >/dev/null; then sudo usermod -aG render "$name"; fi
    note "$name: no password, no login; home $home, which only it can enter."
}

# browser_files NAME: the folder you and it share, outside your home: it gets no passage through
# your home. Root's, so it can't change who else gets in. ~/Browsers/NAME on your side.
browser_files() {
    local name=$1 dir=$BROWSER_FILES/$1
    sudo install -d -o root -g root -m 0755 "$BROWSER_FILES"
    sudo install -d -o root -g root -m 0770 "$dir"
    sudo setfacl -P -m "u:$(id -un):rwx,u:$name:rwx" "$dir"
    sudo setfacl -P -d -m "u:$(id -un):rwx,u:$name:rwx" "$dir"
    if [ -e "$HOME/Browsers" ] && [ ! -L "$HOME/Browsers" ]; then
        warn "$HOME/Browsers is already there, so the folders you share with these browsers are only at $BROWSER_FILES."
    else
        ln -sfn "$BROWSER_FILES" "$HOME/Browsers"
    fi
}

# browser_commands NAME: the command, by NAME, and the session script, root's
browser_commands() {
    sudo install -d -o root -g root -m 0755 /usr/local/libexec
    sudo install -o root -g root -m 0755 "$WREN_DIR/bin/browser-sandbox" "$BROWSER_CMD"
    sudo install -o root -g root -m 0755 "$WREN_DIR/bin/browser-session" "$BROWSER_SESSION"
    sudo ln -sfn browser-sandbox "/usr/local/bin/$1"
}

# browser_allow NAME: your user may run the session script as NAME, and nothing else. A broken
# sudoers file locks you out of sudo, so visudo checks it first. A limit on processes keeps a
# runaway page from filling the machine.
browser_allow() {
    local name=$1 rule
    rule=$(mktemp)
    printf '# Written by Wren Linux lib/browser-sandbox.sh. %s may run the browser session script,\n# and nothing else, as the user %s.\n%s ALL=(%s) NOPASSWD: %s\n' \
        "$(id -un)" "$name" "$(id -un)" "$name" "$BROWSER_SESSION" >"$rule"
    if ! sudo visudo -cf "$rule" >/dev/null; then
        rm -f "$rule"
        die "The sudoers rule didn't pass visudo's check, so it wasn't installed."
    fi
    sudo install -o root -g root -m 0440 "$rule" "/etc/sudoers.d/wren-$name"
    rm -f "$rule"
    printf '# Written by Wren Linux lib/browser-sandbox.sh\n%s hard nproc 4096\n%s hard core 0\n' "$name" "$name" |
        sudo tee "/etc/security/limits.d/50-wren-$name.conf" >/dev/null
}

# browser_firewall NAME: its rule, loaded before the network comes up, at every boot
browser_firewall() {
    local name=$1 file=/etc/wren/$1.nft table=wren_${1//-/_} unit=wren-$1-firewall.service
    sudo install -d -o root -g root -m 0755 /etc/wren
    sudo install -o root -g root -m 0644 "$WREN_DIR/data/$name-firewall.nft" "$file"
    # (loading it empties its resolvers set: filled again right after, parts/sandbox-dns.sh)
    printf '%s\n' '# Written by Wren Linux lib/browser-sandbox.sh' '[Unit]' \
        "Description=Keep the $name user off this computer and your network (Wren Linux)" \
        'Before=network-pre.target' 'Wants=network-pre.target' '' '[Service]' \
        'Type=oneshot' 'RemainAfterExit=yes' \
        "ExecStart=/usr/sbin/nft -f $file" \
        'ExecStartPost=-/usr/local/libexec/wren-sandbox-dns' \
        "ExecStop=/usr/sbin/nft delete table inet $table" '' '[Install]' \
        'WantedBy=multi-user.target' | sudo tee "/etc/systemd/system/$unit" >/dev/null
    sudo systemctl daemon-reload
    # Restart, not just enable --now: a changed rule file is only loaded by a restart
    sudo systemctl enable "$unit" >/dev/null && sudo systemctl restart "$unit" ||
        warn "Couldn't load the firewall rule, so it won't start; check: systemctl status $unit"
}

# browser_clip_key: the key that moves a line of text in or out (bin/browser-sandbox)
browser_clip_key() {
    put_snippet browser-sandbox <<SNIPPET
# Move one line of text in or out of a sandboxed browser (Wren Linux lib/browser-sandbox.sh)
bindsym \$mod+Ctrl+b exec foot -e $BROWSER_CMD clip-window
SNIPPET
}

# browser_remove NAME: all but its packages. Its home and the folder you share are asked about
# or kept: they can hold bookmarks and downloads. The shared command goes with the last browser.
browser_remove() {
    local name=$1 home=/home/$1 other left=false
    sudo systemctl disable --now "wren-$name-firewall.service" >/dev/null 2>&1 || true
    sudo nft delete table inet "wren_${name//-/_}" 2>/dev/null || true
    sudo rm -f "/etc/sudoers.d/wren-$name" "/etc/security/limits.d/50-wren-$name.conf" \
        "/usr/local/bin/$name" "/etc/wren/$name.nft" "/etc/systemd/system/wren-$name-firewall.service"
    sudo systemctl daemon-reload >/dev/null 2>&1 || true
    for other in "${BROWSER_NAMES[@]}"; do
        [ ! -e "/usr/local/bin/$other" ] || left=true
    done
    if ! $left; then
        remove_snippet browser-sandbox
        sudo rm -f "$BROWSER_CMD" "$BROWSER_SESSION"
        if [ -L "$HOME/Browsers" ]; then rm -f "$HOME/Browsers"; fi
    fi
    if id "$name" >/dev/null 2>&1; then
        sudo pkill -KILL -u "$name" 2>/dev/null || true
        if sudo test -d "$home" && ask "Also delete this browser's bookmarks and settings ($home)?" n; then
            sudo userdel -r "$name" 2>/dev/null || { sudo userdel "$name"; sudo rm -rf "$home"; }
        else
            sudo userdel "$name"
            sudo test ! -d "$home" || note "Kept $home (only root can read it now)."
        fi
        note "Removed the user $name."
    fi
    if sudo test -d "$BROWSER_FILES/$name"; then
        note "What it saved stays in $BROWSER_FILES/$name; delete it yourself when you don't need it."
    fi
}

# ---- Tor Browser itself: shared by extras/tor-browser.sh (Safest) and
# extras/tor-browser-unsafe.sh (Safer). Each sets, before calling these: NAME (its user),
# TB_BASE/TBB/PROFILE/USERJS (where the browser and profile live in that user's home),
# OFFLINE (the saved download), TB_PREFS_SRC (the level's user.js data file), POLICIES_SRC
# (the level's policy file), TB_LEVEL_NOTES (what to say once the level is set), and
# OLD_USERJS (only tor-browser: the copy from before it had a user of its own).
JS_BEGIN='// >>> Wren Linux extras/tor-browser.sh >>>'
JS_END='// <<< Wren Linux <<<'

# shellcheck disable=SC2153  # NAME and the rest are the callers' globals (the contract above)
# install_browser: unpacks the newest copy prep/fetch-tor-browser.sh saved, checked against the
# developers' key, as its user, from standard input: it can't reach your files, and nothing is
# written as root in its home. It fetches one if there is none. Once there, it updates itself.
install_browser() {
    local copies tarball
    if as_browser "$NAME" test -e "$TBB/Browser/start-tor-browser"; then
        note "Tor Browser is there already (it updates itself, through Tor)."
        return 0
    fi
    copies=("$OFFLINE"/tor-browser-linux-"$(uname -m)"-*.tar.xz)
    if [ ! -f "${copies[-1]}" ]; then
        note "Downloading Tor Browser from torproject.org, without Tor: your network can see that"
        note "you fetched it (not what you do with it). prep/fetch-tor-browser.sh on another"
        note "network, beforehand, saves a copy that is used instead."
        bash "$WREN_DIR/prep/fetch-tor-browser.sh" || die "Couldn't fetch Tor Browser. Nothing was started."
        copies=("$OFFLINE"/tor-browser-linux-"$(uname -m)"-*.tar.xz)
    fi
    tarball=$(printf '%s\n' "${copies[@]}" | sort -V | tail -1)
    verify_detached "$TB_FPR" "$OFFLINE/tor-browser-developers.asc" "$tarball.asc" "$tarball" ||
        die "The saved copy $tarball didn't pass its signature check. Run prep/fetch-tor-browser.sh again."
    as_browser "$NAME" mkdir -p "$TB_BASE"
    as_browser "$NAME" tar -xJ -C "$TB_BASE" <"$tarball" || die "Couldn't unpack $tarball."
    note "Installed Tor Browser from $(basename -- "$tarball"), checked against the developers' key."
}

tor_browser_running() { sudo pgrep -u "$NAME" >/dev/null 2>&1; }

# our_block: what user.js holds from an earlier run: its own, or the copy that ran as you. It
# comes from a file a hacked browser could change, so the callers below take only what is
# shaped as it should be.
our_block() {
    { as_browser "$NAME" cat "$USERJS" 2>/dev/null || { [ -n "${OLD_USERJS:-}" ] && cat "$OLD_USERJS"; } 2>/dev/null || true; } |
        sed -n "\|^$JS_BEGIN\$|,\|^$JS_END\$|p"
}

# Our block's bridge lines, if any, from an earlier run
current_bridges() {
    local line
    our_block | sed -n 's/^user_pref("torbrowser\.settings\.bridges\.bridge_strings\.[0-9]*", "\(.*\)");$/\1/p' |
        while IFS= read -r line; do
            if [[ $line =~ ^(obfs4|webtunnel)\ [^\"\\[:cntrl:]]+$ ]]; then printf '%s\n' "$line"; fi
        done
}

# Our block's built-in bridge type (obfs4, snowflake, meek), if any
current_builtin() {
    case $(our_block | sed -n 's/^user_pref("torbrowser\.settings\.bridges\.builtin_type", "\(.*\)");$/\1/p' | head -1) in
        obfs4) echo obfs4 ;;
        snowflake) echo snowflake ;;
        meek) echo meek ;;
    esac
}

# The level's prefs (TB_PREFS_SRC), plus bridges. Written by its user, from standard input.
write_userjs() {
    local i tmp
    tmp=$(mktemp)
    as_browser "$NAME" cat "$USERJS" 2>/dev/null | sed "\|^$JS_BEGIN\$|,\|^$JS_END\$|d" >"$tmp" || true
    {
        echo "$JS_BEGIN"
        cat "$TB_PREFS_SRC"
        # source: 0 = Tor Browser's built-in bridges, 2 = lines you provide
        case $BRIDGE_TYPE in
            obfs4 | webtunnel)
                echo 'user_pref("torbrowser.settings.bridges.enabled", true);'
                echo 'user_pref("torbrowser.settings.bridges.source", 2);'
                for i in "${!BRIDGES[@]}"; do
                    printf 'user_pref("torbrowser.settings.bridges.bridge_strings.%d", "%s");\n' "$i" "${BRIDGES[$i]}"
                done ;;
            obfs4-builtin | snowflake | meek)
                echo 'user_pref("torbrowser.settings.bridges.enabled", true);'
                echo 'user_pref("torbrowser.settings.bridges.source", 0);'
                printf 'user_pref("torbrowser.settings.bridges.builtin_type", "%s");\n' "${BRIDGE_TYPE%-builtin}" ;;
        esac
        echo "$JS_END"
    } >>"$tmp"
    # shellcheck disable=SC2016  # expanded by the inner bash, on purpose
    as_browser "$NAME" bash -c 'umask 077; mkdir -p "$1"; cat >"$1/.user.js.wren"; mv -f "$1/.user.js.wren" "$1/user.js"' _ "$PROFILE" <"$tmp"
    rm -f "$tmp"
    # Bridges saved earlier from the browser's settings would be used too,
    # so drop them; Tor Browser rewrites prefs.js from user.js at start.
    if [ "$BRIDGE_TYPE" != none ]; then
        as_browser "$NAME" sed -i '/^user_pref("torbrowser\.settings\.bridges\.bridge_strings\./d' "$PROFILE/prefs.js" 2>/dev/null || true
    fi
}

# The lock on the security level: Tor Browser's policy engine reads
# Browser/distribution/policies.json at every start, a write to a locked pref is silently
# ignored (so the shield's dialog can't move the slider), and BlockAboutConfig closes the
# other way in. The updater's manifest (precomplete) names only shipped files, so the file
# survives the browser's own updates. Written by its user, like user.js: root writes
# nothing in its home.
write_policies() {
    # shellcheck disable=SC2016  # expanded by the inner bash, on purpose
    as_browser "$NAME" bash -c 'umask 077; mkdir -p "$1"; cat >"$1/.policies.json.wren"; mv -f "$1/.policies.json.wren" "$1/policies.json"' _ "$TBB/Browser/distribution" <"$POLICIES_SRC"
}

prepare_profile() {
    local old old_builtin
    if tor_browser_running; then
        warn "Tor Browser is running. Quit it and run this again to set its security level and bridges."
        return 0
    fi
    mapfile -t old < <(current_bridges)
    old_builtin=$(current_builtin)
    if [ ${#old[@]} -gt 0 ] && ask "Keep the ${#old[@]} bridge line(s) set up last time?" y; then
        BRIDGES=("${old[@]}")
        BRIDGE_TYPE=obfs4
        [[ ${old[0]} != webtunnel* ]] || BRIDGE_TYPE=webtunnel
    elif [ -n "$old_builtin" ] && ask "Keep using Tor Browser's built-in $old_builtin bridges, set up last time?" y; then
        BRIDGE_TYPE=$old_builtin
        [ "$BRIDGE_TYPE" != obfs4 ] || BRIDGE_TYPE=obfs4-builtin
    else
        pick_bridges tor-browser
    fi
    write_userjs
    write_policies
    local line
    for line in "${TB_LEVEL_NOTES[@]}"; do note "$line"; done
    if [ "$BRIDGE_TYPE" != none ]; then
        note "Its first connection goes through a bridge: $(bridge_summary)."
    else
        note "No bridge: Tor Browser connects to Tor directly. Run this again to set one up,"
        note "or choose one in its settings before you click Connect."
    fi
}

# ---- the torbrowser-launcher AppArmor profiles: shared by both tor-browser extras.
# These edit /etc/apparmor.d/local/*, the package's own drop-in for site changes.

# The package's AppArmor profile lets Tor read /etc/resolv.conf, but that
# is usually a symlink and AppArmor checks the real file. Plain Tor never
# notices (relays are IP addresses), but webtunnel and other bridges given
# as hostnames fail with "Temporary failure in name resolution".
TOR_PROFILE=/etc/apparmor.d/torbrowser.Tor.tor
TOR_LOCAL=/etc/apparmor.d/local/torbrowser.Tor.tor
DNS_BEGIN='# >>> Wren Linux: DNS through the resolv.conf symlink >>>'
DNS_END='# <<< Wren Linux <<<'
# Tor Browser 16 moved torrc-defaults, geoip and geoip6 from TorBrowser/Data/Tor
# to TorBrowser/Tor, where the package's profile (torbrowser-launcher 0.3.7)
# doesn't let Tor read them. torrc-defaults holds the ClientTransportPlugin
# lines, so every bridge then fails: "there is no configured transport called
# snowflake" (obfs4, webtunnel too). Only reading those three files is added.
TB16_BEGIN='# >>> Wren Linux: what Tor Browser 16 keeps in TorBrowser/Tor >>>'
TB16_RULES=('owner @{torbrowser_home_dir}/TorBrowser/Tor/torrc-defaults r,'
    'owner @{torbrowser_home_dir}/TorBrowser/Tor/geoip r,'
    'owner @{torbrowser_home_dir}/TorBrowser/Tor/geoip6 r,')

fix_apparmor_dns() {
    [ -f "$TOR_PROFILE" ] || return 0
    if ! sudo grep -qxF "$DNS_BEGIN" "$TOR_LOCAL" 2>/dev/null; then
        printf '%s\n' "$DNS_BEGIN" \
            '@{run}/resolvconf/resolv.conf r,' \
            '@{run}/NetworkManager/{,no-stub-}resolv.conf r,' \
            '@{run}/systemd/resolve/{,stub-}resolv.conf r,' \
            "$DNS_END" | sudo tee -a "$TOR_LOCAL" >/dev/null
        note "Let Tor's AppArmor profile read resolv.conf, for bridges given as hostnames."
    fi
    if ! sudo grep -qxF "$TB16_BEGIN" "$TOR_LOCAL" 2>/dev/null; then
        # (the same rules added by hand go: they would be there twice)
        for r in "${TB16_RULES[@]}"; do
            sudo sed -i "\\|^$r\$|d" "$TOR_LOCAL" 2>/dev/null || true
        done
        printf '%s\n' "$TB16_BEGIN" "${TB16_RULES[@]}" "$DNS_END" | sudo tee -a "$TOR_LOCAL" >/dev/null
        note "Let Tor's AppArmor profile read Tor Browser 16's torrc-defaults: its bridges' programs are named there."
    fi
    if [ -d /sys/kernel/security/apparmor ]; then
        sudo apparmor_parser -r "$TOR_PROFILE"
    fi
}

# Its profile lets the browser save only in its own Downloads, and reach sound only at the
# usual places: this lets it save in the folder you share with it, and reach its own sound
# server, in memory. Both are this user's alone.
BROWSER_PROFILE=/etc/apparmor.d/torbrowser.Browser.firefox
BROWSER_LOCAL=/etc/apparmor.d/local/torbrowser.Browser.firefox
SANDBOX_BEGIN='# >>> Wren Linux: Tor Browser as its own user >>>'
# The tor-browser-family users (each runs the same browser under the one torbrowser.Browser.firefox
# profile, which attaches to /home/*/). Mullvad Browser isn't one: no torbrowser-launcher profile.
TOR_BROWSER_USERS=(tor-browser tor-browser-unsafe)
# tor_browser_sibling NAME: another tor-browser-family command is installed (so the shared
# AppArmor fixes and the apparmor profile reparse must stay). The command link in /usr/local/bin
# is what browser_commands makes and browser_remove takes away, so it tracks "installed".
tor_browser_sibling() {
    local name=$1 u
    for u in "${TOR_BROWSER_USERS[@]}"; do
        [ "$u" = "$name" ] && continue
        [ -e "/usr/local/bin/$u" ] && return 0
    done
    return 1
}
fix_apparmor_browser() {
    local u lines=("$SANDBOX_BEGIN")
    [ -f "$BROWSER_PROFILE" ] || return 0
    # Each user's own share folder (its Downloads) and its own sound pipe. A rule for a user that
    # isn't installed names a path that never exists, so nothing matches it (AppArmor fails closed).
    for u in "${TOR_BROWSER_USERS[@]}"; do
        lines+=("$BROWSER_FILES/$u/ rw," "$BROWSER_FILES/$u/** rwk,"
            "owner /dev/shm/$u.*/run/pulse/ r," "owner /dev/shm/$u.*/run/pulse/native rw,")
    done
    lines+=("$DNS_END")
    # Rewritten each run (so a machine that had only tor-browser's rule broadens to both)
    if [ -f "$BROWSER_LOCAL" ]; then
        sudo sed -i "\\|^$SANDBOX_BEGIN\$|,\\|^$DNS_END\$|d" "$BROWSER_LOCAL"
    fi
    printf '%s\n' "${lines[@]}" | sudo tee -a "$BROWSER_LOCAL" >/dev/null
    if [ -d /sys/kernel/security/apparmor ]; then
        sudo apparmor_parser -r "$BROWSER_PROFILE"
    fi
}

unfix_apparmor_browser() {
    [ -f "$BROWSER_LOCAL" ] || return 0
    sudo sed -i "\\|^$SANDBOX_BEGIN\$|,\\|^$DNS_END\$|d" "$BROWSER_LOCAL"
    if [ -f "$BROWSER_PROFILE" ] && [ -d /sys/kernel/security/apparmor ]; then
        sudo apparmor_parser -r "$BROWSER_PROFILE" || true
    fi
}

unfix_apparmor_dns() {
    [ -f "$TOR_LOCAL" ] || return 0
    sudo sed -i "\\|^$DNS_BEGIN\$|,\\|^$DNS_END\$|d" "$TOR_LOCAL"
    sudo sed -i "\\|^$TB16_BEGIN\$|,\\|^$DNS_END\$|d" "$TOR_LOCAL"
    # Loaded rules stay until the profile is read again: read it now, without them
    if [ -f "$TOR_PROFILE" ] && [ -d /sys/kernel/security/apparmor ]; then
        sudo apparmor_parser -r "$TOR_PROFILE" || true
    fi
}