#!/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). lpFetchIndex() { local cache="${1:-}" base channel tmp idx sig json valid_until nowts serial last base="$(lpReleaseBaseUrl)"; channel="$(lpReleaseChannel)" [[ -n "$(_lpFetchTool)" ]] || { isError "lpFetchIndex: need curl or wget"; return 1; } tmp="$(mktemp -d)"; idx="$tmp/index.json"; sig="$tmp/index.json.minisig" if ! _lpDownload "$base/$channel/index.json" "$idx"; 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). _lpDownload "$base/$channel/index.json.minisig" "$sig" 2>/dev/null || true if ! lpVerifyMinisig "$idx" "$sig" >/dev/null; then rm -rf "$tmp"; return 1; fi json="$(cat "$idx")" # Freshness — refuse a signed-but-stale feed. valid_until="$(_lpJsonNum "$json" valid_until)" if [[ -n "$valid_until" ]]; then nowts="$(date +%s 2>/dev/null)" if [[ -n "$nowts" ]] && (( valid_until < nowts )); then isError "lpFetchIndex: artifact index is stale (valid_until elapsed) — refusing"; rm -rf "$tmp"; return 1 fi fi # Anti-rollback — serial must not go backwards from the highest accepted. serial="$(_lpJsonNum "$json" index_serial)" last="$(lpArtifactLastSerial)" if [[ -n "$serial" ]] && (( serial < last )); then isError "lpFetchIndex: index_serial $serial below last-seen $last (rollback) — refusing"; rm -rf "$tmp"; return 1 fi [[ -n "$serial" ]] && 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 -> 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 -> 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 }