Wren Linux

wren-linux / lib / offline.sh

# shellcheck shell=bash
# lib/offline.sh: checks that the .debs saved in offline/debs are really
# Debian's. Debian's packages aren't signed one by one; the archive signs
# its Release file, which lists the hash of each Packages file, which
# lists the hash of each .deb. prep/fetch-offline-debs.sh saves the signed
# Release files and Packages files next to the .debs (offline/debs/verify/),
# and wifi.sh walks that chain with Debian's own archive keyring before it
# installs anything from a USB stick. Sourced after lib.sh.

# The archives packages can come from: NAME BASE-URL SUITE. NAME is the
# folder under verify/.
offline_sources() {
    printf '%s\n' \
        "debian https://deb.debian.org/debian $DEBIAN_RELEASE" \
        "updates https://deb.debian.org/debian $DEBIAN_RELEASE-updates" \
        "security https://security.debian.org/debian-security $DEBIAN_RELEASE-security"
}
# Components with Wi-Fi software: firmware is in non-free-firmware
OFFLINE_COMPONENTS=(main non-free-firmware)

# fetch_offline_lists DIR: downloads each archive's InRelease and Packages
# files into DIR/NAME/ (COMPONENT_Packages.gz, or COMPONENT_Packages when
# the archive has no .gz; mirrors only serve that one compressed, so it is
# unpacked here). Nothing is trusted yet; verify_offline_lists
# does that.
fetch_offline_lists() {
    local dir=$1 name base suite comp rel want file
    rm -rf "$dir"
    while read -r name base suite; do
        mkdir -p "$dir/$name"
        if ! wget -q -O "$dir/$name/InRelease" "$base/dists/$suite/InRelease"; then
            warn "No $suite archive at $base; skipped."
            rm -rf "${dir:?}/$name"
            continue
        fi
        for comp in "${OFFLINE_COMPONENTS[@]}"; do
            # The Release file lists the names it has: prefer the gzipped one
            if grep -qE " $comp/binary-amd64/Packages\.gz\$" "$dir/$name/InRelease"; then
                want=Packages.gz
            elif grep -qE " $comp/binary-amd64/Packages\$" "$dir/$name/InRelease"; then
                want=Packages
            else
                continue
            fi
            file="$dir/$name/${comp}_$want"
            if [ "$want" = Packages ]; then
                # Mirrors only serve it compressed. The Release file also lists
                # the hash of the plain file, so that is what gets saved.
                command -v xz >/dev/null || die "xz is missing. Install it with: sudo apt install xz-utils"
                wget -q -O - "$base/dists/$suite/$comp/binary-amd64/Packages.xz" | xz -dc >"$file" ||
                    die "Couldn't download $base/dists/$suite/$comp/binary-amd64/Packages.xz"
            else
                wget -q -O "$file" "$base/dists/$suite/$comp/binary-amd64/$want" ||
                    die "Couldn't download $base/dists/$suite/$comp/binary-amd64/$want"
            fi
        done
    done < <(offline_sources)
}

# release_fresh FILE NAME: FILE is the signed text of NAME's InRelease. A
# signature proves who made a list, not when: someone who can write to the
# USB stick could put back an older, genuine, vulnerable set. Debian's
# security and updates lists say how long they are good for (Valid-Until,
# a week); the stable list has none, and keeps the date of the last point
# release (up to about two months), so that one is only refused when it is
# older than OFFLINE_MAX_AGE_DAYS. A date in the future is let through: a
# computer with no network often has a clock that is wrong. Returns 1, with
# a reason on stderr, for an old list. WREN_OFFLINE_ALLOW_STALE=1 accepts
# old lists on purpose; WREN_NOW (seconds) is for tests.
OFFLINE_MAX_AGE_DAYS=120
release_fresh() {
    local file=$1 name=$2 now date_s until_s date_t until_t
    [ "${WREN_OFFLINE_ALLOW_STALE:-}" != 1 ] || return 0
    now=${WREN_NOW:-$(date +%s)}
    date_s=$(sed -n 's/^Date: *//p' "$file" | head -1)
    until_s=$(sed -n 's/^Valid-Until: *//p' "$file" | head -1)
    # (date -d "" means today, so an empty Date is checked for first)
    date_t=
    if [ -n "$date_s" ]; then date_t=$(date -d "$date_s" +%s 2>/dev/null) || date_t=; fi
    if [ -z "$date_t" ]; then
        warn "$name/InRelease has no readable Date, so how old it is can't be checked."
    elif [ "$now" -gt $((date_t + OFFLINE_MAX_AGE_DAYS * 86400)) ]; then
        warn "$name/InRelease is from $date_s, more than $OFFLINE_MAX_AGE_DAYS days ago."
    elif [ -n "$until_s" ] && { until_t=$(date -d "$until_s" +%s 2>/dev/null) || until_t=0; [ "$now" -gt "$until_t" ]; }; then
        warn "$name/InRelease was only valid until $until_s."
    else
        return 0
    fi
    warn "Make the packages again on a machine you trust (bash prep/fetch-offline-debs.sh),"
    warn "or set WREN_OFFLINE_ALLOW_STALE=1 to accept old lists on purpose."
    return 1
}

# offline_good_until DIR: until when wifi.sh will take the saved lists in DIR
# (DIR/NAME/InRelease), as seconds since 1970: the earliest of each list's
# Date plus OFFLINE_MAX_AGE_DAYS and its Valid-Until. Nothing, if DIR has no
# list or one has no readable Date. (Only the dates: verify_offline_lists
# checks the signatures.)
offline_good_until() {
    local f d u t best=
    for f in "$1"/*/InRelease; do
        [ -f "$f" ] || continue
        d=$(sed -n 's/^Date: *//p' "$f" | head -1)
        [ -n "$d" ] && t=$(date -d "$d" +%s 2>/dev/null) || return 0
        t=$((t + OFFLINE_MAX_AGE_DAYS * 86400))
        u=$(sed -n 's/^Valid-Until: *//p' "$f" | head -1)
        if [ -n "$u" ] && u=$(date -d "$u" +%s 2>/dev/null) && [ "$u" -lt "$t" ]; then t=$u; fi
        if [ -z "$best" ] || [ "$t" -lt "$best" ]; then best=$t; fi
    done
    [ -z "$best" ] || echo "$best"
}

# verify_offline_lists DIR OUT: checks every DIR/NAME/InRelease against
# Debian's archive keyring and every Packages file against its InRelease,
# then writes the SHA-256 of every package they list to OUT. The signature is
# checked with sqv, which Debian 13's apt depends on, so it is there on a new
# install without a network; gpgv only if sqv isn't (it may not be installed). Returns 1, with
# a reason on stderr, if anything doesn't check out.
# check_inrelease HOME OUT INRELEASE --keyring K...: the signed text of
# INRELEASE into OUT, if one of the keyrings signed it
check_inrelease() {
    local home=$1 out=$2 file=$3 k sqvargs=()
    shift 3
    if command -v sqv >/dev/null; then
        while [ $# -gt 0 ]; do k=$2; sqvargs+=("--keyring=$k"); shift 2; done
        # (sqv won't write over a file)
        rm -f "$out"
        sqv "${sqvargs[@]}" --output="$out" --cleartext "$file" >/dev/null 2>&1
    else
        gpgv --quiet --homedir "$home" "$@" --output "$out" "$file" 2>/dev/null
    fi
}

verify_offline_lists() {
    local dir=$1 out=$2 k keyargs=() home rel d name pk base comp want got n=0 rc=0
    for k in /usr/share/keyrings/debian-archive-*.pgp /usr/share/keyrings/debian-archive-*.gpg; do
        if [ -r "$k" ] && [[ $k != *removed* ]]; then keyargs+=(--keyring "$k"); fi
    done
    if [ ${#keyargs[@]} -eq 0 ]; then
        warn "Debian's archive keyring isn't installed (package debian-archive-keyring)."
        return 1
    fi
    home=$(mktemp -d)
    rel=$(mktemp)
    : >"$out"
    for d in "$dir"/*/; do
        [ -f "$d/InRelease" ] || continue
        name=$(basename -- "$d")
        if ! check_inrelease "$home" "$rel" "$d/InRelease" "${keyargs[@]}"; then
            warn "The signature on $name/InRelease isn't Debian's."
            rc=1
            break
        fi
        if ! release_fresh "$rel" "$name"; then
            rc=1
            break
        fi
        for pk in "$d"/*_Packages "$d"/*_Packages.gz; do
            [ -f "$pk" ] || continue
            base=$(basename -- "$pk")
            comp=${base%%_Packages*}
            want=$(awk -v p="$comp/binary-amd64/${base#"${comp}"_}" '
                /^SHA256:/ { s = 1; next }
                /^[A-Za-z]/ { s = 0 }
                s && $3 == p { print $1; exit }' "$rel")
            got=$(sha256sum "$pk" | cut -d' ' -f1)
            if [ -z "$want" ] || [ "$want" != "$got" ]; then
                warn "$name/$base doesn't match the signed Release file."
                rc=1
                break 2
            fi
            case $pk in
                *.gz) zcat "$pk" ;;
                *) cat "$pk" ;;
            esac | awk '/^SHA256:/ { print $2 }' >>"$out"
            n=$((n + 1))
        done
    done
    rm -rf "$home" "$rel"
    if [ "$rc" -eq 0 ] && [ "$n" -eq 0 ]; then
        warn "No package lists to check against."
        rc=1
    fi
    return "$rc"
}

# check_offline_debs REPO SUMS: every .deb in REPO, and every package its
# Packages index describes, must have a SHA-256 from the verified lists, and
# the index may only name files in REPO itself.
check_offline_debs() {
    local repo=$1 sums=$2 f h bad=0
    for f in "$repo"/*.deb; do
        [ -f "$f" ] || continue
        h=$(sha256sum "$f" | cut -d' ' -f1)
        if ! grep -qxF "$h" "$sums"; then
            warn "${f##*/} isn't a package from Debian's archive (or is an old version of one)."
            bad=1
        fi
    done
    while read -r h; do
        grep -qxF "$h" "$sums" || { warn "Packages index lists a package that isn't Debian's."; bad=1; break; }
    done < <(awk '/^SHA256:/ { print $2 }' "$repo/Packages")
    if grep '^Filename:' "$repo/Packages" | grep -qvE '^Filename: \./[^/]+\.deb$'; then
        warn "Packages index points outside the folder."
        bad=1
    fi
    return "$bad"
}