LibrePortal/scripts/source/artifacts.sh
librelad caee74bd76 feat(distribution): signed artifact-index fetch+verify primitive (Phase 1)
Build the read side of the unified distribution primitive from
docs/roadmap/updates-and-distribution.md: one team-signed catalog
(index.json) on the same channel as latest.json, listing type-tagged
artifact envelopes. A hotfix is the first artifact type; apps/themes/
components are future envelope rows through the SAME pipe — the
marketplace seam is just the `type` + `payload.kind` fields.

Phase 1 is fetch + verify + parse only (NO mutation; the snapshot →
ops → rollback → History apply verb is Phase 2):

- Factor `lpVerifyMinisig` out of `lpFetchRelease` (scripts/source/
  fetch.sh) — one trust anchor (the root-owned footprint key) now
  shared by releases and the index; refactor `lpFetchRelease` to use
  it (behaviour-preserving, still fail-closed).
- scripts/source/artifacts.sh: `lpFetchIndex` — download →
  verify-before-parse → `valid_until` freshness (anti-withholding) →
  `index_serial` monotonic high-water (anti-rollback, TUF-lite) → emit
  verified JSON. Trust core is jq-free; parsing accessors prefer jq
  with a grep fallback.
- `libreportal artifact index` (scripts/cli/commands/artifact/) —
  read-only front door that fetches, verifies and lists. Runs directly
  like `updater check` (no task; no mutation).
- Regenerate the source arrays + lazy-load function manifest for the
  new files.

Doc: promote the format from vision to spec (§8) — 3 layers
(INDEX/ENVELOPE/PIPELINE), the bounded declarative op vocabulary (no
run-script, ever), the apply pipeline mapped onto existing functions,
the marketplace seam, and resolutions for all five open forks.

Self-tested 12/12: trust core fails closed (real key + no minisign →
refuse), happy path, stale-refused, rollback-refused, signature-refused,
jq + grep parsing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-31 16:48:06 +01:00

116 lines
5.9 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).
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 <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
}