A 4-lens adversarial security review of the Phase 2 applier raised 19 issues and confirmed 17 after per-finding verification. All are trust-boundary (they require the signing key), but several break the explicit "no code-exec, always reversible, nothing-silent" contract, so all 17 are fixed: Trust path — fail CLOSED, never misreport: - lpFetchIndex now surfaces the real signature state (LP_INDEX_SIGSTATE); artifactApply REFUSES to mutate unless the index is actually verified, and _artifactFetchPayload refuses an unsigned payload. The read path still tolerates dev/unsigned but now says "UNSIGNED" instead of "Signed + verified". - valid_until and index_serial are now MANDATORY + numeric in lpFetchIndex (missing = refuse) — closes the anti-withholding / anti-rollback fail-opens. Injection / code-exec (defense in depth even for a signed payload): - runFileWrite rootless branch no longer builds a `bash -c` shell string with the destination interpolated — it uses the argv form (like runFileOp), so a path with a quote can't inject a command as the install user. (shared-helper fix) - op paths must match a safe-filename charset (no quotes/$/backtick/;/newline); set-config-key values and set-compose-image refs are charset-guarded too. - content_b64 is validated as real base64 at precheck. Reversibility / honest failure: - dockerComposeUp now returns the real compose exit status (it always returned 0, so the updater's rollback gate AND the apply's start-failure detection were fail-open). (shared-helper fix) - set-config-key undo captures the WHOLE config file (lossless) instead of a lossy re-parsed scalar; edit-only (rejects an absent key). - _artifactReplayUndoFile returns non-zero if any inverse op fails; auto-rollback and revert now record "rollback-incomplete"/"revert-incomplete" + isError instead of falsely claiming success, and revert keeps the record for retry. - applied-record write failure is checked — apply rolls back rather than leave an un-revertable change. System-scope regen failure is no longer swallowed. - Writes are path-aware (configs/ -> runInstallWrite, container tree -> runFileWrite) so system-scope hotfixes write/restore correctly. - Checked lazy-sourcing surfaces a clear error instead of a bare exit 127. Unit-tested 35/35 (adds: command-sub value rejection, bad image-ref, invalid base64, quote/metachar path-injection rejection, replay-failure reporting). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
133 lines
7.1 KiB
Bash
133 lines
7.1 KiB
Bash
#!/bin/bash
|
|
#
|
|
# LibrePortal artifact-index helpers — the READ side of the unified distribution
|
|
# primitive (see docs/roadmap/updates-and-distribution.md).
|
|
#
|
|
# An "artifact" is anything LibrePortal pulls from the outside and applies
|
|
# reversibly: a HOTFIX today; apps / themes / components later. They all share
|
|
# ONE team-signed catalog — the INDEX — published in the SAME release tree as
|
|
# latest.json: $base/$channel/index.json (+ index.json.minisig).
|
|
#
|
|
# This file is PHASE 1 of that primitive: fetch + verify + parse the index. It
|
|
# performs NO mutation. The apply pipeline (snapshot → declarative ops → verify →
|
|
# auto-rollback → History) is Phase 2 (scripts/cli/commands/artifact). Keeping
|
|
# the read side here means the trust core is testable on its own and the WebUI
|
|
# scan can surface "available artifacts" before any apply machinery exists.
|
|
#
|
|
# Trust chain — fail-closed at every step once the footprint key is real:
|
|
# footprint pubkey --signs--> index.json --lists--> per-artifact {sha256, sig}
|
|
# Verification reuses lpVerifyMinisig (fetch.sh) — the EXACT anchor the release
|
|
# fetch uses — so the manager can't bless a forged catalog any more than a forged
|
|
# release. Two transparency guarantees, both jq-free so the trust core never
|
|
# depends on jq being present:
|
|
# valid_until — refuse a stale/withheld feed. A signed feed that simply stops
|
|
# advancing is the silent-withholding / targeting attack the
|
|
# warrant-canary model exists to defeat; treat a frozen feed as
|
|
# a signal, not as "no updates".
|
|
# index_serial — monotonic counter; refuse a serial below the highest we have
|
|
# already accepted (a rollback that re-introduces a pulled or
|
|
# again-vulnerable entry).
|
|
|
|
# The index sits next to latest.json on the same channel; reuse those resolvers
|
|
# (lpReleaseBaseUrl/lpReleaseChannel live in fetch.sh).
|
|
lpArtifactIndexUrl() { echo "$(lpReleaseBaseUrl)/$(lpReleaseChannel)/index.json"; }
|
|
|
|
# Runtime-owned high-water mark for index_serial (the anti-rollback anchor). It
|
|
# lives alongside the other generated updater data so it ships/clears with that
|
|
# state; the dir is in the container tree, so writes go through the container
|
|
# funnel. Reads are fine as any user (world-readable).
|
|
lpArtifactSerialFile() { echo "${containers_dir%/}/libreportal/frontend/data/updater/generated/.index_serial"; }
|
|
lpArtifactLastSerial() { local v; v=$(cat "$(lpArtifactSerialFile)" 2>/dev/null | tr -dc '0-9'); echo "${v:-0}"; }
|
|
lpArtifactRecordSerial() {
|
|
local serial="$1" f; f="$(lpArtifactSerialFile)"
|
|
[[ "$serial" =~ ^[0-9]+$ ]] || return 0
|
|
runFileOp mkdir -p "$(dirname "$f")" 2>/dev/null || true
|
|
printf '%s\n' "$serial" | runFileWrite "$f"
|
|
}
|
|
|
|
# Fetch + verify the signed artifact index.
|
|
# $1 (optional): also cache the verified JSON to this path (for the WebUI scan).
|
|
# Echoes the verified JSON to stdout on success. Returns non-zero (printing
|
|
# nothing usable) on ANY download / signature / freshness / rollback failure —
|
|
# callers MUST NOT proceed on a non-zero return (fail-closed).
|
|
# On success, sets the global LP_INDEX_SIGSTATE to "verified" or "unsigned" so
|
|
# callers can distinguish a real signature from signing-not-activated (dev). The
|
|
# READ path tolerates "unsigned" (dev/git installs); the MUTATING apply path must
|
|
# refuse it (see artifactApply) — that asymmetry is the whole point of surfacing it.
|
|
LP_INDEX_SIGSTATE=""
|
|
lpFetchIndex() {
|
|
local cache="${1:-}" base channel tmp idx sig json valid_until nowts serial last
|
|
base="$(lpReleaseBaseUrl)"; channel="$(lpReleaseChannel)"
|
|
LP_INDEX_SIGSTATE=""
|
|
[[ -n "$(_lpFetchTool)" ]] || { isError "lpFetchIndex: need curl or wget"; return 1; }
|
|
|
|
tmp="$(mktemp -d)"; idx="$tmp/index.json"; sig="$tmp/index.json.minisig"
|
|
# Silence the downloader's own stderr (curl's "could not resolve host" / 404
|
|
# noise) — the caller's clean error message covers the failure. Consistent
|
|
# with the .minisig fetch below.
|
|
if ! _lpDownload "$base/$channel/index.json" "$idx" 2>/dev/null; then
|
|
isError "lpFetchIndex: could not download the artifact index"; rm -rf "$tmp"; return 1
|
|
fi
|
|
|
|
# Signature FIRST — never parse an unverified document to make trust
|
|
# decisions. Fetch the .minisig best-effort; lpVerifyMinisig decides whether
|
|
# a missing/invalid signature is fatal (it is, once the key is real) and
|
|
# echoes the resulting state, which we record for the apply-path gate.
|
|
_lpDownload "$base/$channel/index.json.minisig" "$sig" 2>/dev/null || true
|
|
LP_INDEX_SIGSTATE="$(lpVerifyMinisig "$idx" "$sig")" || { rm -rf "$tmp"; return 1; }
|
|
|
|
json="$(cat "$idx")"
|
|
|
|
# Freshness — a valid index MUST carry a future valid_until. Missing / non-
|
|
# numeric / elapsed all refuse (a withheld or undated feed is the attack the
|
|
# anti-withholding guarantee exists to defeat — fail-closed, not fail-open).
|
|
valid_until="$(_lpJsonNum "$json" valid_until)"
|
|
nowts="$(date +%s 2>/dev/null)"
|
|
if [[ -z "$valid_until" ]]; then
|
|
isError "lpFetchIndex: index has no numeric valid_until — refusing (anti-withholding)"; rm -rf "$tmp"; return 1
|
|
fi
|
|
if [[ -n "$nowts" ]] && (( valid_until < nowts )); then
|
|
isError "lpFetchIndex: artifact index is stale (valid_until elapsed) — refusing"; rm -rf "$tmp"; return 1
|
|
fi
|
|
|
|
# Anti-rollback — a valid index MUST carry index_serial, and it must not go
|
|
# backwards from the highest accepted (missing serial = missing anchor = refuse).
|
|
serial="$(_lpJsonNum "$json" index_serial)"
|
|
if [[ -z "$serial" ]]; then
|
|
isError "lpFetchIndex: index has no numeric index_serial — refusing (anti-rollback anchor missing)"; rm -rf "$tmp"; return 1
|
|
fi
|
|
last="$(lpArtifactLastSerial)"
|
|
if (( serial < last )); then
|
|
isError "lpFetchIndex: index_serial $serial below last-seen $last (rollback) — refusing"; rm -rf "$tmp"; return 1
|
|
fi
|
|
lpArtifactRecordSerial "$serial"
|
|
|
|
[[ -n "$cache" ]] && printf '%s' "$json" | runFileWrite "$cache"
|
|
printf '%s' "$json"
|
|
rm -rf "$tmp"
|
|
return 0
|
|
}
|
|
|
|
# --- Parsing accessors -------------------------------------------------------
|
|
# The trust-critical fields (index_serial / valid_until / signature) are read
|
|
# jq-free above so the security core has no jq dependency. Enumerating the
|
|
# artifacts ARRAY for display is best-effort: jq when present (the runtime path
|
|
# has it — updaterRecordHistory already relies on it), with a flat grep fallback.
|
|
|
|
lpIndexTop() { _lpJsonStr "$2" "$1"; } # lpIndexTop <field> <json> -> top-level scalar
|
|
|
|
lpIndexArtifactIds() { # echo one artifact id per line
|
|
local json="$1"
|
|
if command -v jq >/dev/null 2>&1; then
|
|
printf '%s' "$json" | jq -r '.artifacts[]?.id // empty' 2>/dev/null
|
|
return 0
|
|
fi
|
|
printf '%s' "$json" | grep -oE '"id"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -E 's/.*"([^"]*)"$/\1/'
|
|
}
|
|
|
|
lpArtifactById() { # lpArtifactById <json> <id> -> the artifact object (jq only)
|
|
local json="$1" id="$2"
|
|
command -v jq >/dev/null 2>&1 || return 1
|
|
printf '%s' "$json" | jq -ce --arg id "$id" '.artifacts[]? | select(.id==$id)' 2>/dev/null
|
|
}
|