diff --git a/.github/workflows/package-repository.yml b/.github/workflows/package-repository.yml new file mode 100644 index 0000000..b7b447c --- /dev/null +++ b/.github/workflows/package-repository.yml @@ -0,0 +1,231 @@ +name: NexaWrt signed APK package repository + +on: + pull_request: + paths: + - 'configs/ax9000-single-ubi*.config' + - 'manifests/package-repository*' + - 'packages/nexawrt-repository/**' + - 'scripts/lock-file-policy.sh' + - 'scripts/package-repository-key.sh' + - 'scripts/prepare.sh' + - 'scripts/stage-package-repository.py' + - 'scripts/validate.sh' + - 'tests/test_package_repository*' + - '.github/workflows/package-repository.yml' + - '.github/workflows/pages.yml' + push: + branches: [main] + paths: + - 'configs/ax9000-single-ubi*.config' + - 'manifests/package-repository*' + - 'packages/nexawrt-repository/**' + - 'scripts/lock-file-policy.sh' + - 'scripts/package-repository-key.sh' + - 'scripts/prepare.sh' + - 'scripts/stage-package-repository.py' + - 'scripts/validate.sh' + - 'tests/test_package_repository*' + - '.github/workflows/package-repository.yml' + - '.github/workflows/pages.yml' + workflow_dispatch: + +permissions: {} + +concurrency: + group: package-repository-${{ github.ref }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + policy: + name: Validate repository policy + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout reviewed source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - name: Install policy dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends openssl shellcheck + - name: Validate signed APK repository policy + run: | + set -euo pipefail + shellcheck -S warning \ + scripts/lock-file-policy.sh \ + scripts/package-repository-key.sh \ + scripts/prepare.sh \ + scripts/validate.sh \ + tests/test_package_repository_policy.sh + bash tests/test_package_repository_policy.sh + NEXAWRT_FLAVOR=official ./scripts/validate.sh + NEXAWRT_FLAVOR=nss ./scripts/validate.sh + + publish: + name: Build, sign, and publish testing repository + needs: policy + if: >- + github.repository == 'tifycloud/NexaWrt' && + github.event_name != 'pull_request' && + github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 180 + environment: package-repository + permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write + env: + WORK_DIR: .work/package-repository + steps: + - name: Checkout trusted main source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Reject mutable or duplicate repository release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + source scripts/lock-file-policy.sh + nexawrt_validate_lock_file manifests/package-repository.lock package-repository + source manifests/package-repository.lock + test "$GITHUB_SHA" = "$(git rev-parse HEAD)" + if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$NEXAWRT_REPOSITORY_RELEASE_TAG" >/dev/null 2>&1; then + echo "Repository release tag already exists; increment NEXAWRT_REPOSITORY_RELEASE instead of overwriting it." >&2 + exit 1 + fi + if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$NEXAWRT_REPOSITORY_RELEASE_TAG" >/dev/null 2>&1; then + echo "Repository release already exists; immutable assets will not be replaced." >&2 + exit 1 + fi + - name: Install OpenWrt build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential clang flex bison g++ gawk gcc-multilib g++-multilib \ + gettext git libncurses-dev libssl-dev python3 python3-setuptools \ + python3-pyelftools python3-packaging rsync swig unzip zlib1g-dev \ + file wget libelf-dev shellcheck subversion time xsltproc zstd + - name: Prepare pinned OpenWrt source and feeds + run: | + set -euo pipefail + NEXAWRT_FLAVOR=official WORK_DIR="$WORK_DIR" ./scripts/prepare.sh --clean + NEXAWRT_FLAVOR=official ./scripts/validate.sh --source "$WORK_DIR" + - name: Build the locked APK package set without a private key + run: | + set -euo pipefail + public_key="$GITHUB_WORKSPACE/manifests/package-repository-public.pem" + make -C "$WORK_DIR" -j"$(nproc)" V=s \ + NEXAWRT_APK_PUBLIC_ONLY=1 \ + BUILD_KEY_APK_PUB="$public_key" \ + package/nexawrt-repository/compile + mkdir -p release-staging/package-input + while IFS= read -r package; do + [[ -n "$package" && "${package:0:1}" != '#' ]] || continue + mapfile -t matches < <(find "$WORK_DIR/bin/packages" -type f -name "${package}-*.apk" -print | LC_ALL=C sort) + ((${#matches[@]} == 1)) || { + printf 'Expected exactly one APK for %s, found %s\n' "$package" "${#matches[@]}" >&2 + printf '%s\n' "${matches[@]}" >&2 + exit 1 + } + install -m 0644 "${matches[0]}" release-staging/package-input/ + done < manifests/package-repository-packages.txt + find release-staging/package-input -type f -name '*.apk' -print -quit | grep -q . + if find release-staging/package-input \( -type l -o -type f -links +1 \) -print -quit | grep -q .; then + echo 'Package input contains a symbolic or hard link.' >&2 + exit 1 + fi + test -x "$WORK_DIR/staging_dir/host/bin/apk" + - name: Materialize and validate signing key + id: signing_key + env: + REPOSITORY_PRIVATE_KEY: ${{ secrets.NEXAWRT_REPOSITORY_SIGNING_PRIVATE_KEY }} + run: | + set -euo pipefail + umask 077 + key="$RUNNER_TEMP/nexawrt-repository-signing.pem" + test -n "$REPOSITORY_PRIVATE_KEY" + printf '%s\n' "$REPOSITORY_PRIVATE_KEY" > "$key" + chmod 0600 "$key" + ./scripts/package-repository-key.sh private "$key" + printf 'path=%s\n' "$key" >> "$GITHUB_OUTPUT" + - name: Build and verify signed repository archive + env: + PRIVATE_KEY_PATH: ${{ steps.signing_key.outputs.path }} + run: | + set -euo pipefail + source scripts/lock-file-policy.sh + nexawrt_validate_lock_file manifests/package-repository.lock package-repository + source manifests/package-repository.lock + mkdir -p release-staging/repository-parent release-staging/assets + python3 scripts/stage-package-repository.py build \ + --input-dir release-staging/package-input \ + --output-dir release-staging/repository-parent/repository \ + --archive "release-staging/assets/$NEXAWRT_REPOSITORY_ASSET" \ + --private-key "$PRIVATE_KEY_PATH" \ + --public-key "$GITHUB_WORKSPACE/manifests/package-repository-public.pem" \ + --apk-executable "$WORK_DIR/staging_dir/host/bin/apk" + archive="release-staging/assets/$NEXAWRT_REPOSITORY_ASSET" + (cd release-staging/assets && sha256sum "$NEXAWRT_REPOSITORY_ASSET" > "$NEXAWRT_REPOSITORY_ASSET.sha256") + "$WORK_DIR/staging_dir/host/bin/apk" adbdump --format json \ + release-staging/repository-parent/repository/packages.adb >/dev/null + test -s "$archive" + - name: Destroy signing key + if: always() + env: + PRIVATE_KEY_PATH: ${{ steps.signing_key.outputs.path }} + run: | + set -euo pipefail + if [[ -n "${PRIVATE_KEY_PATH:-}" && -f "$PRIVATE_KEY_PATH" && ! -L "$PRIVATE_KEY_PATH" ]]; then + chmod 0600 "$PRIVATE_KEY_PATH" + rm -f -- "$PRIVATE_KEY_PATH" + fi + - name: Upload signed repository workflow artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: signed-apk-repository-${{ github.run_id }}-${{ github.run_attempt }} + path: release-staging/assets/ + if-no-files-found: error + retention-days: 30 + - name: Attest signed repository archive + uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + with: + subject-path: release-staging/assets/*.tar.gz + - name: Publish immutable prerelease assets + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + source scripts/lock-file-policy.sh + nexawrt_validate_lock_file manifests/package-repository.lock package-repository + source manifests/package-repository.lock + gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref="refs/tags/$NEXAWRT_REPOSITORY_RELEASE_TAG" \ + -f sha="$GITHUB_SHA" >/dev/null + cat > "$RUNNER_TEMP/package-repository-notes.md" <&2 exit 1 fi + - name: Download, verify, and stage the signed APK repository + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + source scripts/lock-file-policy.sh + nexawrt_validate_lock_file manifests/package-repository.lock package-repository + source manifests/package-repository.lock + release_json=.pages-index/package-repository-release.json + release_error=.pages-index/package-repository-release.error + if ! gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/tags/$NEXAWRT_REPOSITORY_RELEASE_TAG" \ + > "$release_json" 2> "$release_error"; then + if grep -Fq 'HTTP 404' "$release_error"; then + echo "Signed APK repository $NEXAWRT_REPOSITORY_RELEASE_TAG is not published; refusing to deploy Pages without the locked repository." >&2 + rm -f "$release_json" "$release_error" + exit 1 + fi + cat "$release_error" >&2 + exit 1 + fi + rm -f "$release_error" + read -r asset_id asset_digest < <(python3 - "$release_json" "$NEXAWRT_REPOSITORY_ASSET" <<'PYDATA' + import json + import re + import sys + + release = json.load(open(sys.argv[1], encoding='utf-8')) + if release.get('draft') is not False: + raise SystemExit('package repository release must be published') + assets = [item for item in release.get('assets', []) if item.get('name') == sys.argv[2]] + if len(assets) != 1: + raise SystemExit('package repository release must contain exactly one locked archive') + asset = assets[0] + digest = asset.get('digest') + if not isinstance(asset.get('id'), int) or not isinstance(digest, str) or not re.fullmatch(r'sha256:[0-9a-f]{64}', digest): + raise SystemExit('package repository release asset lacks a valid GitHub digest') + print(asset['id'], digest.removeprefix('sha256:')) + PYDATA + ) + mkdir -p .pages-index/package-repository + archive=.pages-index/package-repository/$NEXAWRT_REPOSITORY_ASSET + gh api \ + -H 'Accept: application/octet-stream' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" > "$archive" + archive_sha="$(sha256sum "$archive" | awk '{print $1}')" + [[ "$archive_sha" == "$asset_digest" ]] || { + echo 'Downloaded package repository archive differs from the GitHub asset digest.' >&2 + exit 1 + } + python3 scripts/stage-package-repository.py stage-pages \ + --archive "$archive" \ + --archive-sha256 "$archive_sha" \ + --github-asset-json "$release_json" \ + --site-dir site + if find site/packages \( -type l -o -type f -links +1 \) -print -quit | grep -q .; then + echo 'The staged APK repository must not contain symbolic or hard links.' >&2 + exit 1 + fi - name: Test Pages UI and policy run: | set -euo pipefail diff --git a/README.md b/README.md index b368fae..00ba3c4 100644 --- a/README.md +++ b/README.md @@ -177,8 +177,9 @@ NEXAWRT_FLAVOR=nss ./scripts/build.sh 构建不会再让 OpenWrt 在源码顶层随机生成 APK 私钥,也不会把长期私钥交给联网的第三方构建机。 当前 initramfs 构建采用**仅公钥信任身份**:仓库提交 `manifests/apk-signing-public.pem`,其规范化 SubjectPublicKeyInfo DER SHA-256 由 `manifests/apk-signing.lock` 锁定;OpenWrt 只嵌入该受信公钥,构建期 -临时 package index 保持未签名并以显式 `--allow-untrusted` 安装。未来若发布可更新 APK 仓库,必须在受控、 -离线的签名环境中使用仓库外私钥单独签署 index,不能把私钥注入固件构建工作流。 +临时 package index 保持未签名并以显式 `--allow-untrusted` 安装。对外软件库使用另一套独立 P-256 身份: +固件只内置仓库公钥和 `packages.adb` URL,受保护的 `package-repository` GitHub Environment 只在受信任的 +`main` 发布 job 中临时提供仓库外私钥,用它单独签署 index,随后删除;私钥不会进入固件构建、artifact、日志或 Pages。 生产构建使用已提交的公钥: @@ -213,6 +214,12 @@ macOS 自带的 Bash、Make 和默认大小写不敏感文件系统通常不满 **Artifacts** 区域下载 `NexaWrt-AX9000--verified-dist-`。该下载仍是 RAM-only 真机测试候选,不是可直接写入闪存的生产刷机包。 +### 自建签名 APK 软件库 + +OpenWrt 25.12 已使用 APK,不再使用 OPKG/IPK。NexaWrt 会把 testing 仓库 URL 和独立公钥直接编译进 AX9000 rootfs;启动后可直接执行 `apk update`、`apk search` 和 `apk add`。GitHub Actions 从固定源码构建审核包,签署 `packages.adb`,创建不可覆盖的版本 Release;Pages 再根据 GitHub asset digest 和归档内摘要安全发布到 `https://tifycloud.github.io/NexaWrt/packages/25.12/testing/aarch64_cortex-a53/`。 + +当前仓库基础设施已经按 `manifests/package-repository.lock` 和 `manifests/package-repository-packages.txt` 锁定,但频道仍是 `testing`。社区组件目录不会自动进入受信软件库;每个软件必须完成源码固定、许可证和构建脚本审查、APK 构建及安装测试。AX9000 当前仍是 RAM-only,运行时安装的软件重启后不会持久保存。设计、命令和 stable 门禁见 [docs/PACKAGE-REPOSITORY.md](docs/PACKAGE-REPOSITORY.md)。 + ### 浏览器自选组件云编译 GitHub Pages 的 **组件 / Components** 区域读取仓库审核过的 `components/catalog.json`,支持 x86_64 和 Xiaomi AX9000、组件搜索、依赖自动补齐与冲突阻止。网页只生成规范化请求和 request hash,不保存 GitHub token,也不接受任意软件包、脚本、路径或 UCI 输入。 diff --git a/configs/ax9000-single-ubi-nss.config b/configs/ax9000-single-ubi-nss.config index 9df1739..d2b04c1 100644 --- a/configs/ax9000-single-ubi-nss.config +++ b/configs/ax9000-single-ubi-nss.config @@ -50,3 +50,6 @@ CONFIG_EXPERIMENTAL=y CONFIG_TOOLCHAINOPTS=y CONFIG_TARGET_OPTIONS=y CONFIG_TARGET_OPTIMIZATION="-O2 -pipe -mcpu=cortex-a53+crc+crypto" + +# Build the NexaWrt repository helper as an APK artifact without installing it in the RAM-test image. +CONFIG_PACKAGE_nexawrt-repository=m diff --git a/configs/ax9000-single-ubi.config b/configs/ax9000-single-ubi.config index fea6306..7bbdfdf 100644 --- a/configs/ax9000-single-ubi.config +++ b/configs/ax9000-single-ubi.config @@ -29,3 +29,6 @@ CONFIG_PACKAGE_tcpdump-mini=y # Keep the first release candidate close to official OpenWrt. # Third-party NSS acceleration/ECM/firmware, proxy suites, containers, storage servers and third-party feeds # are intentionally excluded until the base image passes hardware testing. + +# Build the NexaWrt repository helper as an APK artifact without installing it in the RAM-test image. +CONFIG_PACKAGE_nexawrt-repository=m diff --git a/docs/PACKAGE-REPOSITORY.md b/docs/PACKAGE-REPOSITORY.md new file mode 100644 index 0000000..44e1d9e --- /dev/null +++ b/docs/PACKAGE-REPOSITORY.md @@ -0,0 +1,89 @@ +# NexaWrt 自建 APK 软件库 + +NexaWrt 的 OpenWrt 基线是 **25.12**。这一代使用 APK 软件包管理器,因此本项目的软件库格式是: + +- 软件包:`*.apk` +- 签名索引:`packages.adb` +- 仓库配置:`/etc/apk/repositories.d/nexawrt.list` +- 仓库公钥:`/etc/apk/keys/nexawrt-repository.pem` + +它不是旧版 OpenWrt 的 OPKG/IPK 软件源,不能把 `Packages.gz`、`.ipk` 或 `/etc/opkg/customfeeds.conf` 直接混入 25.12 镜像。 + +## 当前 testing 仓库 + +- 频道:`testing` +- OpenWrt 系列:`25.12` +- 架构:`aarch64_cortex-a53` +- 索引:`https://tifycloud.github.io/NexaWrt/packages/25.12/testing/aarch64_cortex-a53/packages.adb` +- 版本锁:`manifests/package-repository.lock` +- 软件集合锁:`manifests/package-repository-packages.txt` +- 公钥:`manifests/package-repository-public.pem` + +NexaWrt AX9000 配置会把仓库 URL 和公钥直接编译进 rootfs。启动后无需手工添加源,可以执行: + +```sh +apk update +apk search nexawrt +apk add --simulate nexawrt-repository +apk add nexawrt-repository +nexawrt-repo-status +``` + +`--simulate` 只做依赖解析,不安装软件,适合先验证仓库可用性。 + +> 当前 AX9000 产物仍是 initramfs RAM-test 镜像。运行时安装的软件和配置位于 RAM,重启后不会保留;这不影响验证仓库、签名和依赖解析,但不能等同于持久化生产固件。 + +## 自动云编译与发布 + +`.github/workflows/package-repository.yml` 负责: + +1. 从固定 OpenWrt commit 和固定 feeds 构建审核通过的软件包; +2. 仅从 `manifests/package-repository-packages.txt` 收集精确包名; +3. 仅在 `apk mkndx` 读取 public-only 构建输入 APK 时显式使用 `--allow-untrusted`,同时使用独立 P-256 仓库私钥签署最终 `packages.adb`; +4. 使用同一个 OpenWrt host `apk`、只含锁定公钥的临时 keys 目录执行真实 `apk verify packages.adb`;验签绝不使用 `--allow-untrusted`; +5. 验签成功后才生成 `SHA256SUMS`、`repository.json` 和版本化 tar.gz; +6. 创建不可覆盖的 GitHub prerelease; +7. 生成 GitHub build provenance attestation。 + +`repository.json` 的 `index` 对象严格绑定已验签 `packages.adb` 的文件名、SHA-256 和大小,并且只有真实验签成功后才写入 JSON 布尔值 `signature_verified: true`。用于验签的临时公钥目录位于仓库输出目录之外,完成后删除,不会进入归档。 + +Pages 工作流从精确 release tag 下载归档,同时核对 GitHub API asset digest、归档 SHA-256、目录边界、文件类型、`repository.json`、`index.signature_verified === true` 和所有 payload 摘要,再安全发布到 `site/packages/`。由于 GitHub asset digest、归档摘要和验签收据共同绑定了生产阶段已验签的精确索引字节,Pages 阶段不需要下载第二个 APK 工具。 + +发布版本不可覆盖。任何软件集合、源码或仓库元数据发生变化,都必须提高 `NEXAWRT_REPOSITORY_RELEASE` 并同步更新 release tag 和 asset 名称。 + +## 私钥边界 + +仓库只提交公钥。私钥: + +- 不得提交到 Git; +- 不得复制进 OpenWrt `TOPDIR`、固件、artifact、日志或 Pages; +- 只允许由受保护的 GitHub Environment `package-repository` 中的 `NEXAWRT_REPOSITORY_SIGNING_PRIVATE_KEY` 提供给受信任的 `main` 分支发布 job; +- 在 step 内以 `0600` 临时文件生成,验证公钥指纹后只用于 `apk mkndx --sign`;随后使用锁定公钥验签最终索引并删除私钥; +- 必须另做离线加密备份。GitHub Actions secret 无法反向导出,丢失私钥后只能轮换公钥并发布新固件。 + +固件 APK 构建信任身份与在线软件库签名身份是两套独立密钥,不应复用。 + +## 如何增加软件 + +不能把网上找到的任意 IPK 直接搬进仓库。新增软件必须: + +1. 固定可信源码仓库和 commit; +2. 审查 Makefile、下载脚本、补丁、默认配置和许可证; +3. 确认支持 OpenWrt 25.12 与 `aarch64_cortex-a53`; +4. 在构建树中生成 APK; +5. 把精确包名加入 `manifests/package-repository-packages.txt`; +6. 更新软件集合 SHA-256 和仓库发行版本; +7. 通过静态测试、构建、签名、Pages 暂存和虚拟/真机安装测试。 + +内核模块必须按精确 OpenWrt commit、kernel version 和 vermagic 分仓,不能把 official 与 NSS 的 kmod 混放。普通 LuCI 应用也不能因“网上存在”就自动获得信任;社区目录只负责发现候选,正式 APK 仓库只收录完成审查和可复现构建的软件。 + +## stable 频道条件 + +当前仓库是 `testing`。升级为 `stable` 前至少需要: + +- GitHub Actions 完整构建和签名链路连续通过; +- Pages 上的 `packages.adb` 与 APK 可在线下载; +- APK 签名验证、`apk update`、模拟安装、实际安装和卸载通过; +- 依赖冲突、空间占用和失败回滚策略验证; +- AX9000 真机网络功能及重启行为验证; +- 独立安全审查批准密钥和发布权限边界。 diff --git a/manifests/package-repository-packages.txt b/manifests/package-repository-packages.txt new file mode 100644 index 0000000..0124e29 --- /dev/null +++ b/manifests/package-repository-packages.txt @@ -0,0 +1,3 @@ +# Packages built into the signed NexaWrt testing repository. +# One exact OpenWrt package name per line; additions require source review. +nexawrt-repository diff --git a/manifests/package-repository-public.pem b/manifests/package-repository-public.pem new file mode 100644 index 0000000..25dbf3e --- /dev/null +++ b/manifests/package-repository-public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE+eHihJzF6UK4goOZd6zvDdkkFLB7 +ZuRec6/3Iq2bu7nG/9d+077xQ+ztzCsjzB9MUij1jvBRuTvaf21FNJFerw== +-----END PUBLIC KEY----- diff --git a/manifests/package-repository.lock b/manifests/package-repository.lock new file mode 100644 index 0000000..e9a1bf8 --- /dev/null +++ b/manifests/package-repository.lock @@ -0,0 +1,12 @@ +# shellcheck shell=sh +NEXAWRT_REPOSITORY_SCHEMA="1" +NEXAWRT_REPOSITORY_CHANNEL="testing" +NEXAWRT_REPOSITORY_SERIES="25.12" +NEXAWRT_REPOSITORY_RELEASE="25.12.5-r1" +NEXAWRT_REPOSITORY_ARCH="aarch64_cortex-a53" +NEXAWRT_REPOSITORY_BASE_URL="https://tifycloud.github.io/NexaWrt/packages/25.12/testing/aarch64_cortex-a53" +NEXAWRT_REPOSITORY_INDEX_URL="https://tifycloud.github.io/NexaWrt/packages/25.12/testing/aarch64_cortex-a53/packages.adb" +NEXAWRT_REPOSITORY_RELEASE_TAG="package-repository-v25.12.5-r1" +NEXAWRT_REPOSITORY_ASSET="nexawrt-apk-repository-25.12.5-r1.tar.gz" +NEXAWRT_REPOSITORY_PUBLIC_SHA256="21e799c1c16b1fae53ad2e5614c5b85beb3841a326116dfb2d80fe136ddb9093" +NEXAWRT_REPOSITORY_PACKAGE_SET_SHA256="846107a0f6451818c6c761837feb4a8a1fdfb2b8d65c3bb6d8c57945fd2f4f2f" diff --git a/packages/nexawrt-repository/Makefile b/packages/nexawrt-repository/Makefile new file mode 100644 index 0000000..b3e52ab --- /dev/null +++ b/packages/nexawrt-repository/Makefile @@ -0,0 +1,34 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=nexawrt-repository +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 +PKGARCH:=all +PKG_LICENSE:=MIT +PKG_MAINTAINER:=NexaWrt Maintainers + +include $(INCLUDE_DIR)/package.mk + +define Package/nexawrt-repository + SECTION:=net + CATEGORY:=NexaWrt + TITLE:=NexaWrt signed APK repository support + DEPENDS:=+apk +endef + +define Package/nexawrt-repository/description + Installs NexaWrt repository metadata and a small repository status helper. + The repository URL and signing key are already embedded in NexaWrt images. +endef + +define Build/Compile +endef + +define Package/nexawrt-repository/install + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_BIN) ./files/nexawrt-repo-status $(1)/usr/sbin/nexawrt-repo-status + $(INSTALL_DIR) $(1)/usr/share/nexawrt + $(INSTALL_DATA) ./files/repository.conf $(1)/usr/share/nexawrt/repository.conf +endef + +$(eval $(call BuildPackage,nexawrt-repository)) diff --git a/packages/nexawrt-repository/files/nexawrt-repo-status b/packages/nexawrt-repository/files/nexawrt-repo-status new file mode 100755 index 0000000..ab0e24c --- /dev/null +++ b/packages/nexawrt-repository/files/nexawrt-repo-status @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +repo_file=/etc/apk/repositories.d/nexawrt.list +key_file=/etc/apk/keys/nexawrt-repository.pem + +[ -r "$repo_file" ] || { echo "NexaWrt repository configuration is missing" >&2; exit 1; } +[ -r "$key_file" ] || { echo "NexaWrt repository public key is missing" >&2; exit 1; } + +printf 'repository=%s\n' "$(sed -n '1p' "$repo_file")" +printf 'public_key=%s\n' "$key_file" +if [ "${1:-}" = update ]; then + exec apk update +fi diff --git a/packages/nexawrt-repository/files/repository.conf b/packages/nexawrt-repository/files/repository.conf new file mode 100644 index 0000000..f8db977 --- /dev/null +++ b/packages/nexawrt-repository/files/repository.conf @@ -0,0 +1,7 @@ +schema=1 +series=25.12 +release=25.12.5-r1 +channel=testing +architecture=aarch64_cortex-a53 +index_url=https://tifycloud.github.io/NexaWrt/packages/25.12/testing/aarch64_cortex-a53/packages.adb +public_sha256=21e799c1c16b1fae53ad2e5614c5b85beb3841a326116dfb2d80fe136ddb9093 diff --git a/scripts/lock-file-policy.sh b/scripts/lock-file-policy.sh index 6633d06..ccdf3ff 100755 --- a/scripts/lock-file-policy.sh +++ b/scripts/lock-file-policy.sh @@ -35,6 +35,14 @@ schemas = { "KIDDIN9_PACKAGES_URL", "KIDDIN9_PACKAGES_SHA256", "KIDDIN9_PACKAGES_SIGNED", "KIDDIN9_CATALOG_SHA256", }, + "package-repository": { + "NEXAWRT_REPOSITORY_SCHEMA", "NEXAWRT_REPOSITORY_CHANNEL", + "NEXAWRT_REPOSITORY_SERIES", "NEXAWRT_REPOSITORY_RELEASE", + "NEXAWRT_REPOSITORY_ARCH", "NEXAWRT_REPOSITORY_BASE_URL", + "NEXAWRT_REPOSITORY_INDEX_URL", "NEXAWRT_REPOSITORY_RELEASE_TAG", + "NEXAWRT_REPOSITORY_ASSET", "NEXAWRT_REPOSITORY_PUBLIC_SHA256", + "NEXAWRT_REPOSITORY_PACKAGE_SET_SHA256", + }, } if schema not in schemas: raise SystemExit(f"unknown lock schema: {schema}") @@ -77,6 +85,26 @@ elif schema == "nss": require("NSS_OPENWRT_BRANCH", r"[A-Za-z0-9._/-]+") require("NSS_PACKAGES_FEED", r"[a-z0-9_]+") require("NSS_SQM_FEED", r"[a-z0-9_]+") +elif schema == "package-repository": + require("NEXAWRT_REPOSITORY_SCHEMA", r"1") + require("NEXAWRT_REPOSITORY_CHANNEL", r"testing|stable") + require("NEXAWRT_REPOSITORY_SERIES", r"[0-9]{2}\.[0-9]{2}") + require("NEXAWRT_REPOSITORY_RELEASE", r"[0-9]{2}\.[0-9]{2}\.[0-9]+-r[1-9][0-9]*") + require("NEXAWRT_REPOSITORY_ARCH", r"aarch64_cortex-a53|x86_64") + require("NEXAWRT_REPOSITORY_BASE_URL", r"https://tifycloud\.github\.io/NexaWrt/packages/[0-9]{2}\.[0-9]{2}/(testing|stable)/(aarch64_cortex-a53|x86_64)") + require("NEXAWRT_REPOSITORY_INDEX_URL", r"https://tifycloud\.github\.io/NexaWrt/packages/[0-9]{2}\.[0-9]{2}/(testing|stable)/(aarch64_cortex-a53|x86_64)/packages\.adb") + require("NEXAWRT_REPOSITORY_RELEASE_TAG", r"package-repository-v[0-9]{2}\.[0-9]{2}\.[0-9]+-r[1-9][0-9]*") + require("NEXAWRT_REPOSITORY_ASSET", r"nexawrt-apk-repository-[0-9]{2}\.[0-9]{2}\.[0-9]+-r[1-9][0-9]*\.tar\.gz") + require("NEXAWRT_REPOSITORY_PUBLIC_SHA256", r"[0-9a-f]{64}") + require("NEXAWRT_REPOSITORY_PACKAGE_SET_SHA256", r"[0-9a-f]{64}") + expected_base = ( + f"https://tifycloud.github.io/NexaWrt/packages/{values['NEXAWRT_REPOSITORY_SERIES']}/" + f"{values['NEXAWRT_REPOSITORY_CHANNEL']}/{values['NEXAWRT_REPOSITORY_ARCH']}" + ) + if values["NEXAWRT_REPOSITORY_BASE_URL"] != expected_base: + raise SystemExit(f"package repository base URL fields are inconsistent: {path}") + if values["NEXAWRT_REPOSITORY_INDEX_URL"] != expected_base + "/packages.adb": + raise SystemExit(f"package repository index URL fields are inconsistent: {path}") else: require("KIDDIN9_FEED", r"[a-z0-9_]+") require("KIDDIN9_PACKAGES_URL", r"https://dl\.openwrt\.ai/releases/25\.12/packages/aarch64_cortex-a53/kiddin9/Packages\.gz") diff --git a/scripts/package-repository-key.sh b/scripts/package-repository-key.sh new file mode 100755 index 0000000..2004e92 --- /dev/null +++ b/scripts/package-repository-key.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +# shellcheck source=lock-file-policy.sh +source "$ROOT_DIR/scripts/lock-file-policy.sh" + +fail() { echo "Package repository key policy refused: $*" >&2; exit 1; } +hash_public_der() { + openssl pkey -pubin -in "$1" -outform DER 2>/dev/null | sha256sum | awk '{print $1}' +} + +nexawrt_validate_lock_file "$ROOT_DIR/manifests/package-repository.lock" package-repository +# shellcheck source=../manifests/package-repository.lock +source "$ROOT_DIR/manifests/package-repository.lock" +PUBLIC_KEY="$ROOT_DIR/manifests/package-repository-public.pem" + +validate_public() { + [[ -f "$PUBLIC_KEY" && ! -L "$PUBLIC_KEY" ]] || fail "public key is missing or unsafe" + [[ "$(stat -c '%h' "$PUBLIC_KEY" 2>/dev/null || stat -f '%l' "$PUBLIC_KEY")" == 1 ]] || + fail "public key must have one hard link" + local actual + actual="$(hash_public_der "$PUBLIC_KEY")" || fail "public key is not valid P-256 PEM" + [[ "$actual" == "$NEXAWRT_REPOSITORY_PUBLIC_SHA256" ]] || fail "public key hash differs from lock" + openssl pkey -pubin -in "$PUBLIC_KEY" -text -noout 2>/dev/null | grep -Eq 'ASN1 OID: prime256v1|NIST CURVE: P-256' || + fail "public key must use P-256" +} + +validate_private() { + local private_key="$1" canonical mode links derived tmp + [[ "$private_key" == /* ]] || fail "private key path must be absolute" + canonical="$(python3 -I - "$private_key" <<'PY' +import os, pathlib, stat, sys +raw = pathlib.Path(sys.argv[1]) +absolute = pathlib.Path(os.path.abspath(raw)) +if raw != absolute: + raise SystemExit("private key path must be canonical") +current = pathlib.Path(absolute.anchor) +for part in absolute.parts[1:]: + current /= part + mode = current.lstat().st_mode + if stat.S_ISLNK(mode): + raise SystemExit("private key path contains symlink") + if current != absolute and not stat.S_ISDIR(mode): + raise SystemExit("private key parent is not a directory") +st = absolute.stat() +if not stat.S_ISREG(st.st_mode) or st.st_uid != os.geteuid() or st.st_nlink != 1: + raise SystemExit("private key file metadata is unsafe") +if st.st_mode & 0o077: + raise SystemExit("private key permissions must be 0600 or stricter") +print(absolute) +PY +)" || fail "private key path or permissions are unsafe" + mode="$(stat -c '%a' "$canonical" 2>/dev/null || stat -f '%Lp' "$canonical")" + links="$(stat -c '%h' "$canonical" 2>/dev/null || stat -f '%l' "$canonical")" + [[ "$mode" == 600 || "$mode" == 400 ]] || fail "private key permissions must be 0600 or 0400" + [[ "$links" == 1 ]] || fail "private key must have one hard link" + openssl ec -in "$canonical" -text -noout 2>/dev/null | grep -Eq 'ASN1 OID: prime256v1|NIST CURVE: P-256' || + fail "private key must be a valid P-256 EC key" + tmp="$(mktemp "${TMPDIR:-/tmp}/nexawrt-repository-public.XXXXXX")" || fail "cannot create temporary public key" + trap 'rm -f -- "$tmp"' RETURN + openssl ec -in "$canonical" -pubout -out "$tmp" >/dev/null 2>&1 || fail "cannot derive repository public key" + derived="$(hash_public_der "$tmp")" || fail "cannot hash derived public key" + [[ "$derived" == "$NEXAWRT_REPOSITORY_PUBLIC_SHA256" ]] || fail "private key does not match the locked public key" + trap - RETURN + rm -f -- "$tmp" +} + +case "${1:-}" in + public) + [[ $# -eq 1 ]] || fail "public takes no path" + validate_public + ;; + private) + [[ $# -eq 2 ]] || fail "private requires one absolute key path" + validate_public + validate_private "$2" + ;; + *) fail "usage: $0 public | private ABSOLUTE_KEY_PATH" ;; +esac diff --git a/scripts/prepare.sh b/scripts/prepare.sh index eff2d52..fe1bdb1 100755 --- a/scripts/prepare.sh +++ b/scripts/prepare.sh @@ -11,6 +11,9 @@ source "$ROOT_DIR/scripts/git-metadata-policy.sh" # shellcheck source=../manifests/upstream.lock nexawrt_validate_lock_file "$ROOT_DIR/manifests/upstream.lock" upstream source "$ROOT_DIR/manifests/upstream.lock" +# shellcheck source=../manifests/package-repository.lock +nexawrt_validate_lock_file "$ROOT_DIR/manifests/package-repository.lock" package-repository +source "$ROOT_DIR/manifests/package-repository.lock" NEXAWRT_FLAVOR="${NEXAWRT_FLAVOR:-official}" WITH_FEEDS=1 CLEAN=0 @@ -702,6 +705,34 @@ rsync -a "$ROOT_DIR/files/" files/ if [[ "$NEXAWRT_FLAVOR" == nss && -d "$ROOT_DIR/files-nss" ]]; then rsync -a "$ROOT_DIR/files-nss/" files/ fi + +repository_public_key="$ROOT_DIR/manifests/package-repository-public.pem" +repository_package_set="$ROOT_DIR/manifests/package-repository-packages.txt" +repository_package_source="$ROOT_DIR/packages/nexawrt-repository" +for required in "$repository_public_key" "$repository_package_set" "$repository_package_source/Makefile"; do + [[ -f "$required" && ! -L "$required" ]] || { + echo "NexaWrt package repository input is missing or unsafe: $required" >&2 + exit 1 + } +done +if find "$repository_package_source" \( -type l -o -type f -links +1 \) -print -quit | grep -q .; then + echo "NexaWrt package repository source must not contain symbolic or hard links" >&2 + exit 1 +fi +repository_package_set_sha="$(if command -v sha256sum >/dev/null 2>&1; then sha256sum -- "$repository_package_set"; else shasum -a 256 -- "$repository_package_set"; fi | awk '{print $1}')" +[[ "$repository_package_set_sha" == "$NEXAWRT_REPOSITORY_PACKAGE_SET_SHA256" ]] || { + echo "NexaWrt package repository package set differs from its lock" >&2 + exit 1 +} +repository_public_sha="$(openssl pkey -pubin -in "$repository_public_key" -outform DER 2>/dev/null | { if command -v sha256sum >/dev/null 2>&1; then sha256sum; else shasum -a 256; fi; } | awk '{print $1}')" +[[ "$repository_public_sha" == "$NEXAWRT_REPOSITORY_PUBLIC_SHA256" ]] || { + echo "NexaWrt package repository public key differs from its lock" >&2 + exit 1 +} +mkdir -p files/etc/apk/keys files/etc/apk/repositories.d package/nexawrt-repository +install -m 0644 "$repository_public_key" files/etc/apk/keys/nexawrt-repository.pem +printf '%s\n' "$NEXAWRT_REPOSITORY_INDEX_URL" > files/etc/apk/repositories.d/nexawrt.list +rsync -a --delete "$repository_package_source/" package/nexawrt-repository/ cp "$SEED_CONFIG" .config assert_existing_feed_checkouts_have_no_history_overrides || exit 1 diff --git a/scripts/stage-package-repository.py b/scripts/stage-package-repository.py new file mode 100755 index 0000000..85090b3 --- /dev/null +++ b/scripts/stage-package-repository.py @@ -0,0 +1,846 @@ +#!/usr/bin/env python3 +"""Build and safely stage a locked NexaWrt APK package repository archive.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import stat +import subprocess +import sys +import tarfile +import tempfile +from pathlib import Path, PurePosixPath +from typing import Any, Iterable + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_LOCK = ROOT / "manifests/package-repository.lock" +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]*$") +SAFE_VALUE_RE = re.compile(r"^[A-Za-z0-9._/:@+-]+$") +SAFE_COMPONENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$") +APK_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+~-]*\.apk$") +MAX_LOCK_BYTES = 64 * 1024 +MAX_JSON_BYTES = 1024 * 1024 +MAX_PUBLIC_KEY_BYTES = 64 * 1024 +DEFAULT_MAX_ARCHIVE_BYTES = 2 * 1024 * 1024 * 1024 +DEFAULT_MAX_MEMBER_BYTES = 512 * 1024 * 1024 +DEFAULT_MAX_TOTAL_BYTES = 4 * 1024 * 1024 * 1024 +DEFAULT_MAX_MEMBERS = 20_000 +COPY_CHUNK = 1024 * 1024 +NOFOLLOW = getattr(os, "O_NOFOLLOW", 0) + + +class RepositoryError(RuntimeError): + """A repository input failed a safety or integrity check.""" + + +def fail(message: str) -> "NoReturn": + raise RepositoryError(message) + + +def _regular_file(path: Path, context: str, maximum: int | None = None) -> os.stat_result: + try: + info = path.lstat() + except OSError as error: + fail(f"unable to inspect {context}: {error}") + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + fail(f"{context} must be a regular, single-link file") + if maximum is not None and (info.st_size <= 0 or info.st_size > maximum): + fail(f"{context} size is outside the allowed limit") + return info + + +def _real_directory(path: Path, context: str) -> Path: + try: + info = path.lstat() + except OSError as error: + fail(f"unable to inspect {context}: {error}") + if not stat.S_ISDIR(info.st_mode) or path.is_symlink(): + fail(f"{context} must be a real directory") + return path.resolve(strict=True) + + +def _safe_existing_parent(path: Path, context: str) -> Path: + parent = path.parent + resolved = _real_directory(parent, f"{context} parent") + if os.path.lexists(path): + fail(f"{context} already exists") + return resolved / path.name + + +def _ensure_real_children(root: Path, parts: Iterable[str], context: str) -> Path: + current = _real_directory(root, context) + for part in parts: + child = current / part + if os.path.lexists(child): + info = child.lstat() + if not stat.S_ISDIR(info.st_mode) or child.is_symlink(): + fail(f"{context} contains a non-directory or symbolic link") + else: + try: + child.mkdir(mode=0o755) + except OSError as error: + fail(f"unable to create {context}: {error}") + current = child.resolve(strict=True) + return current + + +def _read_bounded_regular(path: Path, context: str, maximum: int) -> bytes: + expected = _regular_file(path, context, maximum) + descriptor = -1 + try: + descriptor = os.open(path, os.O_RDONLY | NOFOLLOW) + current = os.fstat(descriptor) + if not stat.S_ISREG(current.st_mode) or current.st_nlink != 1: + fail(f"{context} changed while opening") + data = bytearray() + while True: + chunk = os.read(descriptor, min(COPY_CHUNK, maximum + 1 - len(data))) + if not chunk: + break + data.extend(chunk) + if len(data) > maximum: + fail(f"{context} exceeds the allowed limit") + final = os.fstat(descriptor) + if (final.st_dev, final.st_ino, final.st_size) != ( + expected.st_dev, + expected.st_ino, + expected.st_size, + ): + fail(f"{context} changed while reading") + return bytes(data) + except OSError as error: + fail(f"unable to read {context}: {error}") + finally: + if descriptor >= 0: + os.close(descriptor) + + +def parse_lock(path: Path) -> dict[str, str]: + raw = _read_bounded_regular(path, "package repository lock", MAX_LOCK_BYTES) + try: + text = raw.decode("ascii") + except UnicodeDecodeError as error: + fail(f"package repository lock must be ASCII: {error}") + values: dict[str, str] = {} + for number, original in enumerate(text.splitlines(), 1): + line = original.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + fail(f"invalid lock line {number}: expected key=value") + key, encoded = line.split("=", 1) + if key != key.strip() or not KEY_RE.fullmatch(key) or key in values: + fail(f"invalid or duplicate lock key on line {number}") + if encoded.startswith(('"', "'")): + quote = encoded[0] + if len(encoded) < 2 or encoded[-1] != quote: + fail(f"unterminated quoted lock value on line {number}") + value = encoded[1:-1] + if quote in value or "\\" in value or "$" in value or "`" in value: + fail(f"unsafe quoted lock value on line {number}") + else: + value = encoded + if not value or not SAFE_VALUE_RE.fullmatch(value): + fail(f"invalid lock value on line {number}") + values[key] = value + return values + + +def _locked(values: dict[str, str], field: str, aliases: Iterable[str]) -> str: + found = {values[name] for name in aliases if name in values} + if not found: + fail(f"package repository lock is missing {field}") + if len(found) != 1: + fail(f"package repository lock has conflicting {field} values") + return found.pop() + + +def _optional_locked(values: dict[str, str], aliases: Iterable[str]) -> str | None: + found = {values[name] for name in aliases if name in values} + if len(found) > 1: + fail("package repository lock contains conflicting aliases") + return found.pop() if found else None + + +def repository_config(lock_path: Path) -> dict[str, Any]: + values = parse_lock(lock_path) + prefix = "NEXAWRT_PACKAGE_REPOSITORY_" + schema = _optional_locked(values, ("NEXAWRT_REPOSITORY_SCHEMA", prefix + "SCHEMA")) + if schema is not None and schema != "1": + fail("package repository lock uses an unsupported schema") + + release = _locked( + values, + "release", + ("NEXAWRT_REPOSITORY_RELEASE", prefix + "RELEASE", prefix + "VERSION", "REPOSITORY_VERSION"), + ) + series = _optional_locked(values, ("NEXAWRT_REPOSITORY_SERIES", prefix + "SERIES")) or release + channel = _locked( + values, + "channel", + ("NEXAWRT_REPOSITORY_CHANNEL", prefix + "CHANNEL", prefix + "TARGET", "REPOSITORY_TARGET"), + ) + architecture = _locked( + values, + "architecture", + ( + "NEXAWRT_REPOSITORY_ARCH", + "NEXAWRT_REPOSITORY_ARCHITECTURE", + prefix + "ARCHITECTURE", + prefix + "ARCH", + "REPOSITORY_ARCHITECTURE", + ), + ) + asset_name = _locked( + values, + "asset name", + ("NEXAWRT_REPOSITORY_ASSET", "NEXAWRT_REPOSITORY_ASSET_NAME", prefix + "ASSET_NAME", "REPOSITORY_ASSET_NAME"), + ) + derived_directory = f"{series}/{channel}/{architecture}" + directory = _optional_locked( + values, + (prefix + "DIRECTORY", prefix + "PATH", "NEXAWRT_REPOSITORY_DIRECTORY", "REPOSITORY_DIRECTORY"), + ) or derived_directory + if directory != derived_directory: + fail("locked repository directory is inconsistent with series, channel, and architecture") + + for label, value in (("series", series), ("release", release), ("channel", channel), ("architecture", architecture)): + if not SAFE_COMPONENT_RE.fullmatch(value): + fail(f"locked repository {label} is unsafe") + directory_path = PurePosixPath(directory) + if directory_path.is_absolute() or not directory_path.parts or any( + part in ("", ".", "..") or not SAFE_COMPONENT_RE.fullmatch(part) for part in directory_path.parts + ): + fail("locked repository directory is unsafe") + if not SAFE_COMPONENT_RE.fullmatch(asset_name): + fail("locked repository asset name is unsafe") + + base_url = _optional_locked(values, ("NEXAWRT_REPOSITORY_BASE_URL", prefix + "BASE_URL")) + index_url = _optional_locked(values, ("NEXAWRT_REPOSITORY_INDEX_URL", prefix + "INDEX_URL")) + if base_url is not None: + expected_suffix = "/packages/" + directory_path.as_posix() + if not base_url.startswith("https://") or not base_url.endswith(expected_suffix): + fail("locked repository base URL is inconsistent with its directory") + if index_url != base_url + "/packages.adb": + fail("locked repository index URL is inconsistent with its base URL") + elif index_url is not None: + fail("locked repository index URL requires a base URL") + + def limit(name: str, default: int) -> int: + raw = _optional_locked(values, (prefix + name, "NEXAWRT_REPOSITORY_" + name)) + if raw is None: + return default + if not raw.isdecimal(): + fail(f"locked {name.lower()} must be a decimal integer") + value = int(raw) + if value <= 0 or value > DEFAULT_MAX_TOTAL_BYTES: + fail(f"locked {name.lower()} is outside the allowed limit") + return value + + config = { + "series": series, + "release": release, + "version": release, + "channel": channel, + "target": channel, + "architecture": architecture, + "asset_name": asset_name, + "directory": directory_path.as_posix(), + "base_url": base_url, + "index_url": index_url, + "release_tag": _optional_locked(values, ("NEXAWRT_REPOSITORY_RELEASE_TAG", prefix + "RELEASE_TAG")), + "public_sha256": _optional_locked(values, ("NEXAWRT_REPOSITORY_PUBLIC_SHA256", prefix + "PUBLIC_SHA256")), + "package_set_sha256": _optional_locked(values, ("NEXAWRT_REPOSITORY_PACKAGE_SET_SHA256", prefix + "PACKAGE_SET_SHA256")), + "max_archive_bytes": limit("MAX_ARCHIVE_BYTES", DEFAULT_MAX_ARCHIVE_BYTES), + "max_member_bytes": limit("MAX_MEMBER_BYTES", DEFAULT_MAX_MEMBER_BYTES), + "max_total_bytes": limit("MAX_TOTAL_BYTES", DEFAULT_MAX_TOTAL_BYTES), + "max_members": limit("MAX_MEMBERS", DEFAULT_MAX_MEMBERS), + } + if config["max_member_bytes"] > config["max_total_bytes"]: + fail("locked member limit exceeds total extraction limit") + return config + + +def sha256_file(path: Path, maximum: int | None = None) -> tuple[str, int]: + expected = _regular_file(path, str(path), maximum) + digest = hashlib.sha256() + descriptor = -1 + total = 0 + try: + descriptor = os.open(path, os.O_RDONLY | NOFOLLOW) + current = os.fstat(descriptor) + if not stat.S_ISREG(current.st_mode) or current.st_nlink != 1: + fail(f"file changed while hashing: {path}") + while True: + chunk = os.read(descriptor, COPY_CHUNK) + if not chunk: + break + total += len(chunk) + if maximum is not None and total > maximum: + fail(f"file exceeds size limit: {path}") + digest.update(chunk) + final = os.fstat(descriptor) + if (final.st_dev, final.st_ino, final.st_size) != (expected.st_dev, expected.st_ino, expected.st_size): + fail(f"file changed while hashing: {path}") + except OSError as error: + fail(f"unable to hash {path}: {error}") + finally: + if descriptor >= 0: + os.close(descriptor) + return digest.hexdigest(), total + + +def copy_regular(source: Path, destination: Path, maximum: int) -> tuple[str, int]: + expected = _regular_file(source, f"APK {source.name}", maximum) + source_fd = destination_fd = -1 + digest = hashlib.sha256() + total = 0 + try: + source_fd = os.open(source, os.O_RDONLY | NOFOLLOW) + opened = os.fstat(source_fd) + if not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1: + fail(f"APK changed while opening: {source.name}") + destination_fd = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL | NOFOLLOW, 0o644) + while True: + chunk = os.read(source_fd, COPY_CHUNK) + if not chunk: + break + total += len(chunk) + if total > maximum: + fail(f"APK exceeds size limit: {source.name}") + digest.update(chunk) + view = memoryview(chunk) + while view: + view = view[os.write(destination_fd, view):] + os.fsync(destination_fd) + final = os.fstat(source_fd) + if (final.st_dev, final.st_ino, final.st_size) != (expected.st_dev, expected.st_ino, expected.st_size): + fail(f"APK changed while copying: {source.name}") + except OSError as error: + fail(f"unable to copy APK {source.name}: {error}") + finally: + if source_fd >= 0: + os.close(source_fd) + if destination_fd >= 0: + os.close(destination_fd) + return digest.hexdigest(), total + + +def _write_new(path: Path, data: bytes, mode: int = 0o644) -> None: + descriptor = -1 + try: + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | NOFOLLOW, mode) + view = memoryview(data) + while view: + view = view[os.write(descriptor, view):] + os.fsync(descriptor) + except OSError as error: + fail(f"unable to write {path.name}: {error}") + finally: + if descriptor >= 0: + os.close(descriptor) + + +def _load_locked_public_key( + public_key_source: Path, + expected_public_sha256: str | None, + timeout: int, +) -> bytes: + if expected_public_sha256 is None or not SHA256_RE.fullmatch(expected_public_sha256): + fail("locked repository public key SHA-256 is missing or invalid") + public_key_data = _read_bounded_regular(public_key_source, "APK public key", MAX_PUBLIC_KEY_BYTES) + try: + parsed = subprocess.run( + ["openssl", "pkey", "-pubin", "-inform", "PEM", "-outform", "DER"], + input=public_key_data, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + close_fds=True, + timeout=timeout, + ) + except (OSError, subprocess.TimeoutExpired) as error: + fail(f"public key validation failed: {type(error).__name__}") + if parsed.returncode != 0 or not parsed.stdout: + fail("APK public key is not a valid PEM public key") + if hashlib.sha256(parsed.stdout).hexdigest() != expected_public_sha256: + fail("APK public key does not match the locked public key SHA-256") + return public_key_data + + +def _verify_signed_index( + apk_executable: Path, + repository: Path, + public_key_data: bytes, + timeout: int, +) -> None: + with tempfile.TemporaryDirectory(prefix="nexawrt-apk-verify.") as raw_keys_dir: + keys_dir = Path(raw_keys_dir) + os.chmod(keys_dir, 0o700) + verification_key = keys_dir / "nexawrt-repository.pem" + _write_new(verification_key, public_key_data, 0o644) + verify_command = [ + str(apk_executable), + "--keys-dir", + str(keys_dir), + "verify", + "packages.adb", + ] + try: + verified = subprocess.run( + verify_command, + cwd=repository, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + close_fds=True, + timeout=timeout, + ) + except (OSError, subprocess.TimeoutExpired) as error: + fail(f"apk verify execution failed: {type(error).__name__}") + if verified.returncode != 0: + fail("apk verify refused or failed for packages.adb") + + +def _archive_repository(source: Path, archive: Path, repository_directory: str) -> None: + mode = "x:gz" if archive.name.endswith(".tar.gz") or archive.name.endswith(".tgz") else "x" + with tarfile.open(archive, mode=mode, format=tarfile.PAX_FORMAT) as output: + for path in sorted(source.iterdir(), key=lambda item: item.name): + _regular_file(path, f"repository file {path.name}") + info = output.gettarinfo(str(path), arcname=f"{repository_directory}/{path.name}") + info.uid = info.gid = 0 + info.uname = info.gname = "" + info.mtime = 0 + info.mode = 0o644 + with path.open("rb") as stream: + output.addfile(info, stream) + + +def command_build(args: argparse.Namespace) -> None: + config = repository_config(args.lock) + input_dir = _real_directory(args.input_dir, "APK input directory") + output_dir = _safe_existing_parent(args.output_dir, "repository output directory") + archive = _safe_existing_parent(args.archive, "repository archive") + if archive.name != config["asset_name"]: + fail("archive filename does not match the locked GitHub asset name") + _regular_file(args.private_key, "APK private key") # Inspect metadata only; never read key bytes. + public_key_data = _load_locked_public_key( + args.public_key, + config["public_sha256"], + args.apk_timeout, + ) + try: + private_key = args.private_key.resolve(strict=True) + except OSError as error: + fail(f"unable to canonicalize APK private key: {error}") + apk_files = sorted((path for path in input_dir.iterdir() if path.name.endswith(".apk")), key=lambda item: item.name) + if not apk_files: + fail("APK input directory contains no packages") + if len(apk_files) + 3 > config["max_members"]: + fail("APK input contains too many packages") + seen: set[str] = set() + for path in apk_files: + if not APK_NAME_RE.fullmatch(path.name) or path.name in seen: + fail(f"unsafe or duplicate APK filename: {path.name}") + seen.add(path.name) + _regular_file(path, f"APK {path.name}", config["max_member_bytes"]) + + temporary = Path(tempfile.mkdtemp(prefix=f".{output_dir.name}.", dir=output_dir.parent)) + completed = False + try: + package_records: list[dict[str, Any]] = [] + for source in apk_files: + digest, size = copy_regular(source, temporary / source.name, config["max_member_bytes"]) + package_records.append({"filename": source.name, "sha256": digest, "size": size}) + command = [ + str(args.apk_executable), + "mkndx", + "--allow-untrusted", + "--sign", + str(private_key), + "--output", + "packages.adb", + ] + command.extend(record["filename"] for record in package_records) + try: + result = subprocess.run( + command, + cwd=temporary, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + close_fds=True, + timeout=args.apk_timeout, + ) + except (OSError, subprocess.TimeoutExpired) as error: + fail(f"apk mkndx execution failed: {type(error).__name__}") + if result.returncode != 0: + fail("apk mkndx refused or failed") + for record in package_records: + digest, size = sha256_file(temporary / record["filename"], config["max_member_bytes"]) + if digest != record["sha256"] or size != record["size"]: + fail(f"apk mkndx modified repository payload: {record['filename']}") + index_path = temporary / "packages.adb" + _regular_file(index_path, "packages.adb", config["max_member_bytes"]) + _verify_signed_index( + args.apk_executable, + temporary, + public_key_data, + args.apk_timeout, + ) + index_digest, index_size = sha256_file(index_path, config["max_member_bytes"]) + checksums = [(record["filename"], record["sha256"]) for record in package_records] + checksums.append(("packages.adb", index_digest)) + checksums.sort() + checksum_data = "".join(f"{digest} {name}\n" for name, digest in checksums).encode("ascii") + _write_new(temporary / "SHA256SUMS", checksum_data) + descriptor = { + "schema_version": 1, + "repository_version": config["release"], + "version": config["release"], + "series": config["series"], + "release": config["release"], + "channel": config["channel"], + "target": config["target"], + "architecture": config["architecture"], + "directory": config["directory"], + "base_url": config["base_url"], + "index_url": config["index_url"], + "release_tag": config["release_tag"], + "asset_name": config["asset_name"], + "public_sha256": config["public_sha256"], + "package_set_sha256": config["package_set_sha256"], + "packages": package_records, + "index": { + "filename": "packages.adb", + "sha256": index_digest, + "size": index_size, + "signature_verified": True, + }, + "checksums": "SHA256SUMS", + } + _write_new( + temporary / "repository.json", + (json.dumps(descriptor, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii"), + ) + os.replace(temporary, output_dir) + completed = True + try: + _archive_repository(output_dir, archive, config["directory"]) + except (OSError, tarfile.TarError) as error: + fail(f"unable to create repository archive: {error}") + archive_digest, archive_size = sha256_file(archive, config["max_archive_bytes"]) + print(json.dumps({"archive": str(archive), "sha256": archive_digest, "size": archive_size}, sort_keys=True)) + finally: + if not completed: + shutil.rmtree(temporary, ignore_errors=True) + + +def _normalize_digest(value: str, context: str) -> str: + digest = value.removeprefix("sha256:").lower() + if not SHA256_RE.fullmatch(digest): + fail(f"{context} is not a valid SHA-256 digest") + return digest + + +def _github_asset_digest(path: Path, expected_name: str) -> str: + raw = _read_bounded_regular(path, "GitHub API asset JSON", MAX_JSON_BYTES) + try: + document = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + fail(f"invalid GitHub API asset JSON: {error}") + assets = document.get("assets") if isinstance(document, dict) else None + if assets is None: + assets = [document] + if not isinstance(assets, list): + fail("GitHub API asset JSON has no asset list") + matches = [asset for asset in assets if isinstance(asset, dict) and asset.get("name") == expected_name] + if len(matches) != 1: + fail("GitHub API response does not contain exactly one locked repository asset") + digest = matches[0].get("digest") + if not isinstance(digest, str): + fail("GitHub API asset has no digest") + return _normalize_digest(digest, "GitHub API asset digest") + + +def _safe_tar_name(name: str) -> str: + if not name or name.startswith("/") or "\\" in name or "\x00" in name: + fail("archive contains an unsafe absolute or malformed path") + path = PurePosixPath(name) + if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts): + fail("archive contains path traversal") + normalized = path.as_posix() + if normalized != name.rstrip("/"): + fail("archive contains a non-canonical path") + return normalized + + +def _validated_members(archive: tarfile.TarFile, config: dict[str, Any]) -> list[tarfile.TarInfo]: + members: list[tarfile.TarInfo] = [] + for member in archive: + members.append(member) + if len(members) > config["max_members"]: + fail("archive member count is outside the allowed limit") + if not members: + fail("archive member count is outside the allowed limit") + seen: set[str] = set() + total = 0 + prefix = config["directory"] + "/" + result: list[tarfile.TarInfo] = [] + for member in members: + name = _safe_tar_name(member.name) + if name in seen: + fail("archive contains duplicate paths") + seen.add(name) + if member.issym() or member.islnk(): + fail("archive contains a symbolic or hard link") + if member.isdev() or member.isfifo() or not (member.isfile() or member.isdir()): + fail("archive contains a device or unsupported special file") + if not (name == config["directory"] or name.startswith(prefix)): + fail("archive member is outside the locked repository directory") + if member.isfile(): + relative = name[len(prefix):] + if not relative or "/" in relative: + fail("repository archive contains nested or misplaced files") + if relative not in ("SHA256SUMS", "repository.json", "packages.adb") and not APK_NAME_RE.fullmatch(relative): + fail("repository archive contains an unexpected file") + if member.size <= 0 or member.size > config["max_member_bytes"]: + fail("archive member size is outside the allowed limit") + total += member.size + if total > config["max_total_bytes"]: + fail("archive expands beyond the allowed total size") + result.append(member) + required = {f"{config['directory']}/{name}" for name in ("SHA256SUMS", "repository.json", "packages.adb")} + if not required.issubset(seen): + fail("archive is missing required repository metadata") + return result + + +def _extract_validated(archive: tarfile.TarFile, members: list[tarfile.TarInfo], destination: Path) -> None: + _real_directory(destination, "temporary extraction directory") + for member in members: + target = destination.joinpath(*PurePosixPath(member.name).parts) + if member.isdir(): + target.mkdir(mode=0o755, parents=True, exist_ok=True) + continue + target.parent.mkdir(mode=0o755, parents=True, exist_ok=True) + source = archive.extractfile(member) + if source is None: + fail("unable to read archive member") + descriptor = -1 + written = 0 + try: + descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | NOFOLLOW, 0o644) + while True: + chunk = source.read(COPY_CHUNK) + if not chunk: + break + written += len(chunk) + if written > member.size: + fail("archive member exceeds its declared size") + view = memoryview(chunk) + while view: + view = view[os.write(descriptor, view):] + if written != member.size: + fail("archive member is truncated") + os.fsync(descriptor) + finally: + source.close() + if descriptor >= 0: + os.close(descriptor) + + +def _load_repository_json(path: Path, config: dict[str, Any]) -> dict[str, Any]: + raw = _read_bounded_regular(path, "repository.json", MAX_JSON_BYTES) + try: + document = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + fail(f"invalid repository.json: {error}") + if not isinstance(document, dict) or document.get("schema_version") != 1: + fail("repository.json uses an unsupported schema") + expected = { + "repository_version": config["release"], + "version": config["release"], + "series": config["series"], + "release": config["release"], + "channel": config["channel"], + "target": config["target"], + "architecture": config["architecture"], + "directory": config["directory"], + "base_url": config["base_url"], + "index_url": config["index_url"], + "release_tag": config["release_tag"], + "asset_name": config["asset_name"], + "public_sha256": config["public_sha256"], + "package_set_sha256": config["package_set_sha256"], + "checksums": "SHA256SUMS", + } + for key, value in expected.items(): + if document.get(key) != value: + fail(f"repository.json has the wrong {key}") + return document + + +def _validate_extracted(root: Path, config: dict[str, Any]) -> None: + repository = root.joinpath(*PurePosixPath(config["directory"]).parts) + _real_directory(repository, "extracted repository directory") + document = _load_repository_json(repository / "repository.json", config) + actual_files = {path.name for path in repository.iterdir() if path.is_file() and not path.is_symlink()} + if any(path.is_dir() or path.is_symlink() for path in repository.iterdir()): + fail("extracted repository contains links or nested directories") + apk_names = sorted(name for name in actual_files if name.endswith(".apk")) + expected_files = set(apk_names) | {"packages.adb", "SHA256SUMS", "repository.json"} + if actual_files != expected_files or not apk_names: + fail("repository file set is incomplete or unexpected") + + checksum_raw = _read_bounded_regular(repository / "SHA256SUMS", "SHA256SUMS", MAX_JSON_BYTES) + try: + lines = checksum_raw.decode("ascii").splitlines() + except UnicodeDecodeError as error: + fail(f"SHA256SUMS must be ASCII: {error}") + checksums: dict[str, str] = {} + for line in lines: + match = re.fullmatch(r"([0-9a-f]{64}) ([A-Za-z0-9][A-Za-z0-9._+~-]*)", line) + if not match or match.group(2) in checksums: + fail("SHA256SUMS contains a malformed or duplicate entry") + checksums[match.group(2)] = match.group(1) + payload_names = set(apk_names) | {"packages.adb"} + if set(checksums) != payload_names: + fail("SHA256SUMS does not exactly cover repository payloads") + payload_details: dict[str, tuple[str, int]] = {} + for name in sorted(payload_names): + digest, size = sha256_file(repository / name, config["max_member_bytes"]) + if digest != checksums[name]: + fail(f"repository payload digest mismatch: {name}") + payload_details[name] = (digest, size) + + packages = document.get("packages") + if not isinstance(packages, list) or len(packages) != len(apk_names): + fail("repository.json package list is invalid") + records: dict[str, dict[str, Any]] = {} + for record in packages: + if not isinstance(record, dict) or set(record) != {"filename", "sha256", "size"}: + fail("repository.json contains a malformed package record") + name = record.get("filename") + if not isinstance(name, str) or name in records: + fail("repository.json contains a duplicate package record") + records[name] = record + if set(records) != set(apk_names): + fail("repository.json package list does not match archive APKs") + for name in apk_names: + digest, size = payload_details[name] + if records[name] != {"filename": name, "sha256": digest, "size": size}: + fail(f"repository.json package metadata mismatch: {name}") + index = document.get("index") + index_digest, index_size = payload_details["packages.adb"] + if not isinstance(index, dict) or set(index) != {"filename", "sha256", "size", "signature_verified"}: + fail("repository.json index metadata is invalid") + if index.get("signature_verified") is not True: + fail("repository.json does not contain a valid signature verification receipt") + if (index.get("filename"), index.get("sha256"), index.get("size")) != ( + "packages.adb", + index_digest, + index_size, + ): + fail("repository.json index metadata is invalid") + + +def command_stage_pages(args: argparse.Namespace) -> None: + config = repository_config(args.lock) + archive_info = _regular_file(args.archive, "repository archive", config["max_archive_bytes"]) + if args.archive.name != config["asset_name"]: + fail("archive filename does not match the locked GitHub asset name") + archive_digest, archive_size = sha256_file(args.archive, config["max_archive_bytes"]) + expected_archive_digest = _normalize_digest(args.archive_sha256, "archive SHA-256") + if archive_digest != expected_archive_digest: + fail("repository archive SHA-256 mismatch") + if args.github_asset_json is not None: + api_digest = _github_asset_digest(args.github_asset_json, config["asset_name"]) + else: + api_digest = _normalize_digest(args.github_asset_digest, "GitHub API asset digest") + if archive_digest != api_digest: + fail("GitHub API asset digest does not match repository archive") + if archive_info.st_size != archive_size: + fail("repository archive changed while being verified") + + site_root = _real_directory(args.site_dir, "site directory") + packages_root = site_root / "packages" + if packages_root.exists() or packages_root.is_symlink(): + packages_root = _real_directory(packages_root, "site packages directory") + else: + packages_root.mkdir(mode=0o755) + packages_root = packages_root.resolve(strict=True) + directory_parts = PurePosixPath(config["directory"]).parts + destination_parent = _ensure_real_children(packages_root, directory_parts[:-1], "Pages repository path") + destination = destination_parent / directory_parts[-1] + if os.path.lexists(destination): + fail("locked Pages repository destination already exists") + temporary = Path(tempfile.mkdtemp(prefix=".repository-stage.", dir=packages_root)) + try: + try: + with tarfile.open(args.archive, mode="r:*") as source: + members = _validated_members(source, config) + _extract_validated(source, members, temporary) + except (OSError, tarfile.TarError) as error: + fail(f"unable to validate repository archive: {error}") + post_digest, post_size = sha256_file(args.archive, config["max_archive_bytes"]) + if post_digest != archive_digest or post_size != archive_size: + fail("repository archive changed during validation") + _validate_extracted(temporary, config) + staged_repository = temporary.joinpath(*PurePosixPath(config["directory"]).parts) + if os.path.lexists(destination): + fail("locked Pages repository destination appeared during staging") + os.replace(staged_repository, destination) + print(json.dumps({"staged": str(destination), "sha256": archive_digest, "size": archive_size}, sort_keys=True)) + finally: + shutil.rmtree(temporary, ignore_errors=True) + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__) + result.add_argument("--lock", type=Path, default=DEFAULT_LOCK) + subcommands = result.add_subparsers(dest="command", required=True) + + build = subcommands.add_parser("build", help="build and archive a signed APK repository") + build.add_argument("--input-dir", type=Path, required=True) + build.add_argument("--output-dir", type=Path, required=True) + build.add_argument("--archive", type=Path, required=True) + build.add_argument("--private-key", type=Path, required=True) + build.add_argument("--public-key", type=Path, required=True) + build.add_argument("--apk-executable", type=Path, default=Path("apk")) + build.add_argument("--apk-timeout", type=int, default=300) + build.set_defaults(handler=command_build) + + stage = subcommands.add_parser("stage-pages", help="verify and safely stage a repository archive for Pages") + stage.add_argument("--archive", type=Path, required=True) + stage.add_argument("--archive-sha256", "--sha256", required=True) + digest_group = stage.add_mutually_exclusive_group(required=True) + digest_group.add_argument("--github-asset-json", "--asset-json", type=Path) + digest_group.add_argument("--github-asset-digest") + stage.add_argument("--site-dir", "--site-root", type=Path, required=True) + stage.set_defaults(handler=command_stage_pages) + return result + + +def main() -> int: + try: + arguments = parser().parse_args() + if getattr(arguments, "apk_timeout", 1) <= 0: + fail("apk timeout must be positive") + arguments.handler(arguments) + return 0 + except RepositoryError as error: + print(f"package repository refused: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate.sh b/scripts/validate.sh index c2db1c9..68b9142 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -11,6 +11,9 @@ source "$ROOT_DIR/scripts/lock-file-policy.sh" # shellcheck source=../manifests/upstream.lock nexawrt_validate_lock_file "$ROOT_DIR/manifests/upstream.lock" upstream source "$ROOT_DIR/manifests/upstream.lock" +# shellcheck source=../manifests/package-repository.lock +nexawrt_validate_lock_file "$ROOT_DIR/manifests/package-repository.lock" package-repository +source "$ROOT_DIR/manifests/package-repository.lock" KERNEL_BUILD_IDENTITY_CHECKER="$ROOT_DIR/scripts/check-kernel-build-identity.sh" NEXAWRT_FLAVOR="${NEXAWRT_FLAVOR:-official}" SOURCE_DIR="" @@ -593,6 +596,8 @@ assert_common_config() { grep -Fq "CONFIG_PACKAGE_${package}=y" "$config" || fail "$description lost required package: $package" done + grep -Fq 'CONFIG_PACKAGE_nexawrt-repository=m' "$config" || + fail "$description does not build the NexaWrt repository bootstrap APK as a module" grep -Fq 'CONFIG_PACKAGE_uboot-envtools=y' "$config" || fail "$description lost the read-only fw_printenv/fw_printsys package" @@ -607,6 +612,71 @@ assert_common_config() { done } +assert_package_repository_inputs() { + local package_set="$ROOT_DIR/manifests/package-repository-packages.txt" + local public_key="$ROOT_DIR/manifests/package-repository-public.pem" + local package_source="$ROOT_DIR/packages/nexawrt-repository" + local package_set_sha public_sha + + [[ -f "$package_set" && ! -L "$package_set" ]] || + fail "package repository package set is missing or unsafe" + [[ -f "$public_key" && ! -L "$public_key" ]] || + fail "package repository public key is missing or unsafe" + [[ -f "$package_source/Makefile" && ! -L "$package_source/Makefile" ]] || + fail "package repository bootstrap source is missing or unsafe" + if find "$package_source" \( -type l -o -type f -links +1 \) -print -quit | grep -q .; then + fail "package repository bootstrap source contains a symbolic or hard link" + fi + package_set_sha="$(if command -v sha256sum >/dev/null 2>&1; then sha256sum -- "$package_set"; else shasum -a 256 -- "$package_set"; fi | awk '{print $1}')" + [[ "$package_set_sha" == "$NEXAWRT_REPOSITORY_PACKAGE_SET_SHA256" ]] || + fail "package repository package set differs from its lock" + public_sha="$(openssl pkey -pubin -in "$public_key" -outform DER 2>/dev/null | + { if command -v sha256sum >/dev/null 2>&1; then sha256sum; else shasum -a 256; fi; } | awk '{print $1}')" + [[ "$public_sha" == "$NEXAWRT_REPOSITORY_PUBLIC_SHA256" ]] || + fail "package repository public key differs from its lock" + "$ROOT_DIR/scripts/package-repository-key.sh" public >/dev/null + grep -Fxq 'nexawrt-repository' "$package_set" || + fail "package repository package set lost the bootstrap package" + cmp -s "$package_source/files/repository.conf" <(cat </dev/null || + fail "prepared NexaWrt repository package differs from policy" +} + assert_flavor_config() { local config="$1" local description="$2" @@ -646,6 +716,8 @@ assert_flavor_config() { fi } +assert_package_repository_inputs + if [[ "$NEXAWRT_FLAVOR" == official ]]; then assert_official_nss_matcher assert_sha OPENWRT "$OPENWRT_COMMIT" @@ -1030,6 +1102,7 @@ PY "target-specific 30_uboot-envtools source" validate_feed_policy "$SOURCE_DIR" + assert_prepared_package_repository "$SOURCE_DIR" for command in sysupgrade firstboot jffs2reset jffs2mark factoryreset mount_root; do prepared_guard="$SOURCE_DIR/files/sbin/$command" @@ -1085,6 +1158,14 @@ PY ((${#rootfs_dirs[@]} == 1)) && [[ -d "${rootfs_dirs[0]}" ]] || fail "expected exactly one final qualcommax rootfs staging directory" rootfs_dir="${rootfs_dirs[0]}" + [[ -f "$rootfs_dir/etc/apk/repositories.d/nexawrt.list" && ! -L "$rootfs_dir/etc/apk/repositories.d/nexawrt.list" ]] || + fail "final rootfs lost the NexaWrt APK repository list" + cmp -s "$rootfs_dir/etc/apk/repositories.d/nexawrt.list" <(printf '%s\n' "$NEXAWRT_REPOSITORY_INDEX_URL") || + fail "final rootfs NexaWrt APK repository URL differs from the lock" + [[ -f "$rootfs_dir/etc/apk/keys/nexawrt-repository.pem" && ! -L "$rootfs_dir/etc/apk/keys/nexawrt-repository.pem" ]] || + fail "final rootfs lost the NexaWrt repository public key" + cmp -s "$ROOT_DIR/manifests/package-repository-public.pem" "$rootfs_dir/etc/apk/keys/nexawrt-repository.pem" || + fail "final rootfs NexaWrt repository public key differs from policy" for command in sysupgrade firstboot jffs2reset jffs2mark factoryreset mount_root; do [[ -x "$rootfs_dir/sbin/$command" ]] || fail "final rootfs lost runtime guard: $command" cmp -s "$ROOT_DIR/files/sbin/$command" "$rootfs_dir/sbin/$command" || diff --git a/tests/test_feed_reuse_policy.sh b/tests/test_feed_reuse_policy.sh index 43dbaca..34be46b 100755 --- a/tests/test_feed_reuse_policy.sh +++ b/tests/test_feed_reuse_policy.sh @@ -85,6 +85,7 @@ mkdir -p \ "$POLICY_REPO/manifests" \ "$POLICY_REPO/configs" \ "$POLICY_REPO/files" \ + "$POLICY_REPO/packages" \ "$POLICY_REPO/patches" \ "$POLICY_REPO/patches/packages" cp "$ROOT_DIR/scripts/prepare.sh" "$POLICY_REPO/scripts/prepare.sh" @@ -93,6 +94,10 @@ cp "$ROOT_DIR/scripts/complete-git-worktree-diff.sh" \ cp "$ROOT_DIR/scripts/sanitize-git-environment.sh" "$POLICY_REPO/scripts/sanitize-git-environment.sh" cp "$ROOT_DIR/scripts/git-metadata-policy.sh" "$POLICY_REPO/scripts/git-metadata-policy.sh" cp "$ROOT_DIR/scripts/lock-file-policy.sh" "$POLICY_REPO/scripts/lock-file-policy.sh" +cp "$ROOT_DIR/manifests/package-repository.lock" "$POLICY_REPO/manifests/package-repository.lock" +cp "$ROOT_DIR/manifests/package-repository-public.pem" "$POLICY_REPO/manifests/package-repository-public.pem" +cp "$ROOT_DIR/manifests/package-repository-packages.txt" "$POLICY_REPO/manifests/package-repository-packages.txt" +cp -R "$ROOT_DIR/packages/nexawrt-repository" "$POLICY_REPO/packages/nexawrt-repository" printf 'fixture package patched\n' > "$FEED_REPO/pkg/Makefile" "$REAL_GIT" -C "$FEED_REPO" diff --binary --no-ext-diff -- pkg/Makefile \ > "$PACKAGES_POLICY_PATCH" @@ -275,9 +280,15 @@ cp "$ROOT_DIR/scripts/sanitize-git-environment.sh" "$VALIDATE_POLICY_REPO/script cp "$ROOT_DIR/scripts/git-metadata-policy.sh" "$VALIDATE_POLICY_REPO/scripts/git-metadata-policy.sh" cp "$ROOT_DIR/scripts/lock-file-policy.sh" "$VALIDATE_POLICY_REPO/scripts/lock-file-policy.sh" cp "$ROOT_DIR/manifests/upstream.lock" "$VALIDATE_POLICY_REPO/manifests/upstream.lock" +cp "$ROOT_DIR/scripts/package-repository-key.sh" "$VALIDATE_POLICY_REPO/scripts/package-repository-key.sh" +cp "$ROOT_DIR/manifests/package-repository.lock" "$VALIDATE_POLICY_REPO/manifests/package-repository.lock" +cp "$ROOT_DIR/manifests/package-repository-public.pem" "$VALIDATE_POLICY_REPO/manifests/package-repository-public.pem" +cp "$ROOT_DIR/manifests/package-repository-packages.txt" "$VALIDATE_POLICY_REPO/manifests/package-repository-packages.txt" +mkdir -p "$VALIDATE_POLICY_REPO/packages" +cp -R "$ROOT_DIR/packages/nexawrt-repository" "$VALIDATE_POLICY_REPO/packages/nexawrt-repository" printf 'packages %s %s\n' "$FEED_REPO" "$FEED_COMMIT" \ > "$VALIDATE_POLICY_REPO/manifests/feeds.lock" -chmod +x "$VALIDATE_POLICY_REPO/scripts/validate.sh" +chmod +x "$VALIDATE_POLICY_REPO/scripts/validate.sh" "$VALIDATE_POLICY_REPO/scripts/package-repository-key.sh" NEXAWRT_FLAVOR=official "$VALIDATE_POLICY_REPO/scripts/validate.sh" \ --source "$REUSE_WORK" --feed-policy-only >/dev/null mkdir -p "$REUSE_WORK/feeds/packages/.git/refs/replace" diff --git a/tests/test_package_repository.py b/tests/test_package_repository.py new file mode 100644 index 0000000..c9ba544 --- /dev/null +++ b/tests/test_package_repository.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import hashlib +import io +import json +import os +import subprocess +import sys +import tarfile +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts/stage-package-repository.py" + + +class PackageRepositoryTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.input_dir = self.root / "input" + self.input_dir.mkdir() + (self.input_dir / "hello-1.0-r0.apk").write_bytes(b"first apk\n") + (self.input_dir / "luci-app-demo-2.0-r1.apk").write_bytes(b"second apk\n") + + self.private_key = self.root / "private.pem" + self.public_key = self.root / "public.pem" + subprocess.run( + ["openssl", "ecparam", "-name", "prime256v1", "-genkey", "-noout", "-out", str(self.private_key)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + self.private_key.chmod(0o600) + subprocess.run( + ["openssl", "ec", "-in", str(self.private_key), "-pubout", "-out", str(self.public_key)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + public_der = subprocess.check_output( + ["openssl", "pkey", "-pubin", "-in", str(self.public_key), "-outform", "DER"], + stderr=subprocess.DEVNULL, + ) + self.public_sha256 = hashlib.sha256(public_der).hexdigest() + self.private_material = self.private_key.read_text(encoding="ascii").strip() + + self.fake_apk_log = self.root / "fake-apk.log" + self.fake_apk = self.root / "fake-apk" + self.fake_apk.write_text( + """#!/usr/bin/env python3 +import hashlib, json, os, pathlib, sys +args = sys.argv[1:] +with open(os.environ['FAKE_APK_LOG'], 'a', encoding='utf-8') as stream: + stream.write(json.dumps(args) + '\\n') +if args and args[0] == 'mkndx': + assert args[0:3] == ['mkndx', '--allow-untrusted', '--sign'] + assert args[4:6] == ['--output', 'packages.adb'] + packages = args[6:] + payload = ''.join(f'{name} {hashlib.sha256(pathlib.Path(name).read_bytes()).hexdigest()}\\n' for name in packages) + public_data = pathlib.Path(os.environ['FAKE_APK_PUBLIC_KEY']).read_bytes() + if os.environ.get('FAKE_APK_UNSIGNED') == '1': + pathlib.Path('packages.adb').write_text(payload, encoding='ascii') + else: + signature = hashlib.sha256(public_data).hexdigest() + pathlib.Path('packages.adb').write_text(f'SIGNED {signature}\\n{payload}', encoding='ascii') + raise SystemExit(0) +if len(args) == 4 and args[0] == '--keys-dir' and args[2:] == ['verify', 'packages.adb']: + assert '--allow-untrusted' not in args + keys = list(pathlib.Path(args[1]).iterdir()) + assert len(keys) == 1 and keys[0].is_file() + signature = hashlib.sha256(keys[0].read_bytes()).hexdigest() + index = pathlib.Path('packages.adb').read_text(encoding='ascii') + if os.environ.get('FAKE_APK_VERIFY_FAIL') == '1' or not index.startswith(f'SIGNED {signature}\\n'): + raise SystemExit(17) + raise SystemExit(0) +raise SystemExit(99) +""", + encoding="utf-8", + ) + self.fake_apk.chmod(0o755) + self.lock = self.root / "package-repository.lock" + self.asset_name = "nexawrt-apk-repository-25.12.5-r1.tar.gz" + self.directory = "25.12/testing/aarch64_cortex-a53" + self.write_lock() + + def tearDown(self) -> None: + self.temporary.cleanup() + + def write_lock(self, architecture: str = "aarch64_cortex-a53") -> None: + self.lock.write_text( + "# strict, non-shell package repository lock\n" + 'NEXAWRT_REPOSITORY_SCHEMA="1"\n' + 'NEXAWRT_REPOSITORY_CHANNEL="testing"\n' + 'NEXAWRT_REPOSITORY_SERIES="25.12"\n' + 'NEXAWRT_REPOSITORY_RELEASE="25.12.5-r1"\n' + f'NEXAWRT_REPOSITORY_ARCH="{architecture}"\n' + f'NEXAWRT_REPOSITORY_BASE_URL="https://tifycloud.github.io/NexaWrt/packages/25.12/testing/{architecture}"\n' + f'NEXAWRT_REPOSITORY_INDEX_URL="https://tifycloud.github.io/NexaWrt/packages/25.12/testing/{architecture}/packages.adb"\n' + 'NEXAWRT_REPOSITORY_RELEASE_TAG="package-repository-v25.12.5-r1"\n' + f'NEXAWRT_REPOSITORY_ASSET="{self.asset_name}"\n' + f'NEXAWRT_REPOSITORY_PUBLIC_SHA256="{self.public_sha256}"\n' + f'NEXAWRT_REPOSITORY_PACKAGE_SET_SHA256="{"2" * 64}"\n' + 'NEXAWRT_REPOSITORY_MAX_ARCHIVE_BYTES="10485760"\n' + 'NEXAWRT_REPOSITORY_MAX_MEMBER_BYTES="1048576"\n' + 'NEXAWRT_REPOSITORY_MAX_TOTAL_BYTES="10485760"\n' + 'NEXAWRT_REPOSITORY_MAX_MEMBERS="100"\n', + encoding="ascii", + ) + + def run_script( + self, + *arguments: object, + expected: int = 0, + extra_env: dict[str, str] | None = None, + ) -> subprocess.CompletedProcess[str]: + command = [sys.executable, str(SCRIPT), "--lock", str(self.lock), *(str(item) for item in arguments)] + environment = os.environ.copy() + environment.update( + { + "FAKE_APK_LOG": str(self.fake_apk_log), + "FAKE_APK_PUBLIC_KEY": str(self.public_key), + } + ) + if extra_env: + environment.update(extra_env) + result = subprocess.run(command, text=True, capture_output=True, check=False, env=environment) + self.assertEqual( + result.returncode, + expected, + msg=f"command: {command!r}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}", + ) + self.assertNotIn(self.private_material, result.stdout) + self.assertNotIn(self.private_material, result.stderr) + return result + + def build( + self, + *, + public_key: Path | None = None, + expected: int = 0, + extra_env: dict[str, str] | None = None, + ) -> tuple[Path, Path, subprocess.CompletedProcess[str]]: + output = self.root / "repository" + archive = self.root / self.asset_name + result = self.run_script( + "build", + "--input-dir", + self.input_dir, + "--output-dir", + output, + "--archive", + archive, + "--private-key", + self.private_key, + "--public-key", + public_key or self.public_key, + "--apk-executable", + self.fake_apk, + expected=expected, + extra_env=extra_env, + ) + return output, archive, result + + @staticmethod + def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + def fake_apk_calls(self) -> list[list[str]]: + if not self.fake_apk_log.exists(): + return [] + return [json.loads(line) for line in self.fake_apk_log.read_text(encoding="utf-8").splitlines()] + + def write_asset_json(self, archive: Path, digest: str | None = None) -> Path: + asset_json = self.root / "asset.json" + asset_json.write_text( + json.dumps({"assets": [{"name": archive.name, "digest": "sha256:" + (digest or self.digest(archive))}]}), + encoding="utf-8", + ) + return asset_json + + def stage( + self, + archive: Path, + *, + sha256: str | None = None, + asset_digest: str | None = None, + expected: int = 0, + ) -> subprocess.CompletedProcess[str]: + site = self.root / "site" + site.mkdir(exist_ok=True) + return self.run_script( + "stage-pages", + "--archive", + archive, + "--archive-sha256", + sha256 or self.digest(archive), + "--github-asset-json", + self.write_asset_json(archive, asset_digest), + "--site-dir", + site, + expected=expected, + ) + + def repack(self, archive: Path, transform) -> None: + extracted = self.root / "repack" + extracted.mkdir() + with tarfile.open(archive, "r:*") as source: + for member in source.getmembers(): + target = extracted.joinpath(*Path(member.name).parts) + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + continue + target.parent.mkdir(parents=True, exist_ok=True) + stream = source.extractfile(member) + assert stream is not None + target.write_bytes(stream.read()) + transform(extracted / self.directory) + archive.unlink() + with tarfile.open(archive, "w:gz") as output: + for path in sorted(item for item in extracted.rglob("*") if item.is_file()): + output.add(path, arcname=path.relative_to(extracted).as_posix(), recursive=False) + + def write_malicious_archive(self, member: tarfile.TarInfo, payload: bytes = b"x") -> Path: + archive = self.root / self.asset_name + with tarfile.open(archive, "w") as output: + if member.isfile(): + member.size = len(payload) + output.addfile(member, io.BytesIO(payload)) + else: + output.addfile(member) + return archive + + def test_successful_build_verifies_signature_and_pages_stage(self) -> None: + output, archive, _ = self.build() + descriptor = json.loads((output / "repository.json").read_text(encoding="ascii")) + self.assertIs(descriptor["index"]["signature_verified"], True) + self.assertEqual(descriptor["index"]["sha256"], self.digest(output / "packages.adb")) + self.assertEqual(descriptor["public_sha256"], self.public_sha256) + calls = self.fake_apk_calls() + self.assertEqual(calls[0][0:3], ["mkndx", "--allow-untrusted", "--sign"]) + self.assertEqual(calls[1][2:], ["verify", "packages.adb"]) + self.assertNotIn("--allow-untrusted", calls[1]) + self.assertFalse(Path(calls[1][1]).exists(), "verification keys directory was not removed") + with tarfile.open(archive, "r:*") as source: + names = source.getnames() + self.assertFalse(any(name.endswith(".pem") or "nexawrt-apk-verify" in name for name in names)) + self.assertFalse(any(self.private_material.encode("ascii") in source.extractfile(member).read() for member in source.getmembers() if member.isfile())) + self.stage(archive) + staged = self.root / "site/packages" / self.directory + self.assertTrue((staged / "packages.adb").is_file()) + + def test_build_rejects_wrong_locked_public_key(self) -> None: + wrong_private = self.root / "wrong-private.pem" + wrong_public = self.root / "wrong-public.pem" + subprocess.run(["openssl", "ecparam", "-name", "prime256v1", "-genkey", "-noout", "-out", str(wrong_private)], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.run(["openssl", "ec", "-in", str(wrong_private), "-pubout", "-out", str(wrong_public)], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + output, archive, result = self.build(public_key=wrong_public, expected=1) + self.assertIn("locked public key", result.stderr.lower()) + self.assertFalse(output.exists()) + self.assertFalse(archive.exists()) + + def test_build_fails_when_apk_verify_rejects_index(self) -> None: + output, archive, result = self.build(expected=1, extra_env={"FAKE_APK_VERIFY_FAIL": "1"}) + self.assertIn("apk verify refused or failed", result.stderr.lower()) + self.assertEqual(self.fake_apk_calls()[-1][2:], ["verify", "packages.adb"]) + self.assertFalse(output.exists()) + self.assertFalse(archive.exists()) + + def test_build_fails_for_unsigned_index(self) -> None: + output, archive, result = self.build(expected=1, extra_env={"FAKE_APK_UNSIGNED": "1"}) + self.assertIn("apk verify refused or failed", result.stderr.lower()) + self.assertFalse(output.exists()) + self.assertFalse(archive.exists()) + + def test_pages_rejects_tampered_signature_receipt(self) -> None: + _, archive, _ = self.build() + + def alter(repository: Path) -> None: + descriptor_path = repository / "repository.json" + descriptor = json.loads(descriptor_path.read_text(encoding="ascii")) + descriptor["index"]["signature_verified"] = False + descriptor_path.write_text(json.dumps(descriptor), encoding="ascii") + + self.repack(archive, alter) + result = self.stage(archive, expected=1) + self.assertIn("signature verification receipt", result.stderr.lower()) + + def test_rejects_path_traversal(self) -> None: + archive = self.write_malicious_archive(tarfile.TarInfo("../../escaped.apk")) + self.stage(archive, expected=1) + self.assertFalse((self.root / "escaped.apk").exists()) + + def test_rejects_symbolic_and_hard_links(self) -> None: + for link_type in (tarfile.SYMTYPE, tarfile.LNKTYPE): + with self.subTest(link_type=link_type): + archive = self.root / self.asset_name + if archive.exists(): + archive.unlink() + member = tarfile.TarInfo(f"{self.directory}/packages.adb") + member.type = link_type + member.linkname = "/etc/passwd" if link_type == tarfile.SYMTYPE else f"{self.directory}/payload.apk" + self.write_malicious_archive(member) + self.assertIn("link", self.stage(archive, expected=1).stderr.lower()) + + def test_rejects_archive_and_api_digest_tampering(self) -> None: + _, archive, _ = self.build() + actual = self.digest(archive) + wrong = "0" * 64 if actual != "0" * 64 else "1" * 64 + self.assertIn("sha-256 mismatch", self.stage(archive, sha256=wrong, expected=1).stderr.lower()) + self.assertIn("github api asset digest", self.stage(archive, asset_digest=wrong, expected=1).stderr.lower()) + + def test_rejects_wrong_repository_architecture(self) -> None: + _, archive, _ = self.build() + + def alter(repository: Path) -> None: + descriptor_path = repository / "repository.json" + descriptor = json.loads(descriptor_path.read_text()) + descriptor["architecture"] = "x86_64" + descriptor_path.write_text(json.dumps(descriptor), encoding="utf-8") + + self.repack(archive, alter) + self.assertIn("wrong architecture", self.stage(archive, expected=1).stderr.lower()) + + def test_build_rejects_symlinked_apk(self) -> None: + target = self.root / "outside.apk" + target.write_bytes(b"outside") + (self.input_dir / "linked.apk").symlink_to(target) + output, _, result = self.build(expected=1) + self.assertIn("regular, single-link", result.stderr) + self.assertFalse(output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_package_repository_policy.sh b/tests/test_package_repository_policy.sh new file mode 100755 index 0000000..26e8956 --- /dev/null +++ b/tests/test_package_repository_policy.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# shellcheck disable=SC1091 +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +mkdir -p "$ROOT_DIR/.work" +TMP="$(mktemp -d "$ROOT_DIR/.work/package-repository-policy.XXXXXX")" +TMP="$(cd "$TMP" && pwd -P)" +trap 'rm -rf -- "$TMP"' EXIT + +fail() { echo "package repository policy test failed: $*" >&2; exit 1; } +expect_rejected() { + local label="$1" expected="$2" + shift 2 + if "$@" >"$TMP/stdout" 2>"$TMP/stderr"; then + fail "$label unexpectedly passed" + fi + grep -Fqi "$expected" "$TMP/stderr" || { + cat "$TMP/stderr" >&2 + fail "$label failed for an unexpected reason" + } +} +hash_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -- "$1" + else + shasum -a 256 -- "$1" + fi +} + +source "$ROOT_DIR/scripts/lock-file-policy.sh" +nexawrt_validate_lock_file "$ROOT_DIR/manifests/package-repository.lock" package-repository +# shellcheck source=../manifests/package-repository.lock +source "$ROOT_DIR/manifests/package-repository.lock" + +"$ROOT_DIR/scripts/package-repository-key.sh" public +actual_set_sha="$(hash_file "$ROOT_DIR/manifests/package-repository-packages.txt" | awk '{print $1}')" +[[ "$actual_set_sha" == "$NEXAWRT_REPOSITORY_PACKAGE_SET_SHA256" ]] || + fail 'package set digest does not match its lock' +grep -Fxq 'nexawrt-repository' "$ROOT_DIR/manifests/package-repository-packages.txt" || + fail 'bootstrap package is not selected' + +cp "$ROOT_DIR/manifests/package-repository.lock" "$TMP/tampered-url.lock" +sed -i.bak 's#/packages.adb#/Packages.gz#' "$TMP/tampered-url.lock" +rm -f "$TMP/tampered-url.lock.bak" +expect_rejected 'OPKG index URL' 'invalid NEXAWRT_REPOSITORY_INDEX_URL' \ + nexawrt_validate_lock_file "$TMP/tampered-url.lock" package-repository + +cp "$ROOT_DIR/manifests/package-repository.lock" "$TMP/inconsistent-url.lock" +sed -i.bak 's#/testing/aarch64_cortex-a53/packages.adb#/stable/aarch64_cortex-a53/packages.adb#' "$TMP/inconsistent-url.lock" +rm -f "$TMP/inconsistent-url.lock.bak" +expect_rejected 'inconsistent APK index URL' 'inconsistent' \ + nexawrt_validate_lock_file "$TMP/inconsistent-url.lock" package-repository + +openssl ecparam -name prime256v1 -genkey -noout -out "$TMP/wrong-private.pem" 2>/dev/null +chmod 600 "$TMP/wrong-private.pem" +expect_rejected 'mismatched private key' 'does not match' \ + "$ROOT_DIR/scripts/package-repository-key.sh" private "$TMP/wrong-private.pem" +chmod 644 "$TMP/wrong-private.pem" +expect_rejected 'world-readable private key' 'permissions' \ + "$ROOT_DIR/scripts/package-repository-key.sh" private "$TMP/wrong-private.pem" + +if grep -RIEq --include='*.conf' --include='*.list' --include='*.lock' \ + '(^|/)(Packages\.gz|[^[:space:]]+\.ipk)([/?#[:space:]]|$)|/etc/opkg|customfeeds\.conf' \ + "$ROOT_DIR/manifests/package-repository.lock" "$ROOT_DIR/packages/nexawrt-repository"; then + fail 'repository policy contains an OPKG/IPK endpoint' +fi + +workflow="$ROOT_DIR/.github/workflows/package-repository.yml" +pages_workflow="$ROOT_DIR/.github/workflows/pages.yml" +[[ -f "$workflow" && ! -L "$workflow" ]] || fail 'package repository workflow is missing or unsafe' +grep -Fq 'environment: package-repository' "$workflow" || fail 'signing job is not environment-gated' +grep -Fq "github.event_name != 'pull_request'" "$workflow" || fail 'untrusted pull requests can reach repository publishing' +grep -Fq "github.ref == 'refs/heads/main'" "$workflow" || fail 'repository publishing is not restricted to main' +grep -Fq 'secrets.NEXAWRT_REPOSITORY_SIGNING_PRIVATE_KEY' "$workflow" || fail 'repository signing secret is not wired explicitly' +grep -Fq 'NEXAWRT_APK_PUBLIC_ONLY=1' "$workflow" || fail 'package build is not public-key-only' +# The dollar expression is intentionally matched literally in workflow YAML. +# shellcheck disable=SC2016 +grep -Fq -- '--public-key "$GITHUB_WORKSPACE/manifests/package-repository-public.pem"' "$workflow" || fail 'workflow does not pass the committed repository public key to the signed index builder' +grep -Fq '"--allow-untrusted",' "$ROOT_DIR/scripts/stage-package-repository.py" || fail 'mkndx does not explicitly allow reading public-only input APKs' +grep -Fq '"verify",' "$ROOT_DIR/scripts/stage-package-repository.py" || fail 'signed packages.adb is not verified with apk' +grep -Fq '"signature_verified": True' "$ROOT_DIR/scripts/stage-package-repository.py" || fail 'repository metadata does not record successful signature verification' +grep -Fq 'index.get("signature_verified") is not True' "$ROOT_DIR/scripts/stage-package-repository.py" || fail 'Pages staging does not fail closed on the signature verification receipt' +grep -Fq 'Repository release tag already exists' "$workflow" || fail 'immutable release guard is missing' +grep -Fq 'actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f' "$workflow" || fail 'repository archive attestation is missing or unpinned' +grep -Fq 'NexaWrt signed APK package repository' "$pages_workflow" || fail 'Pages is not triggered after repository publication' +grep -Fq 'asset.get('"'"'digest'"'"')' "$pages_workflow" || fail 'Pages does not require the GitHub asset digest' +grep -Fq 'scripts/stage-package-repository.py stage-pages' "$pages_workflow" || fail 'Pages does not safely stage the signed repository archive' +grep -Fq 'refusing to deploy Pages without the locked repository' "$pages_workflow" || fail 'Pages does not fail closed when the locked repository release is missing' +for config in "$ROOT_DIR/configs/ax9000-single-ubi.config" "$ROOT_DIR/configs/ax9000-single-ubi-nss.config"; do + grep -Fxq 'CONFIG_PACKAGE_nexawrt-repository=m' "$config" || + fail "repository bootstrap package is not built as a module in $(basename "$config")" +done +grep -Fq 'files/etc/apk/repositories.d/nexawrt.list' "$ROOT_DIR/scripts/prepare.sh" || + fail 'prepare does not embed the APK repository URL' +grep -Fq 'files/etc/apk/keys/nexawrt-repository.pem' "$ROOT_DIR/scripts/prepare.sh" || + fail 'prepare does not embed the APK repository public key' + +python3 "$ROOT_DIR/tests/test_package_repository.py" +echo 'package repository lock, key, workflow, archive, Pages, and APK-only policy: OK' diff --git a/tests/test_pages_policy.sh b/tests/test_pages_policy.sh index 07733fa..db92a23 100755 --- a/tests/test_pages_policy.sh +++ b/tests/test_pages_policy.sh @@ -172,7 +172,7 @@ grep -Fq "default-src 'self'" "$SITE/index.html" # Pages deployment uses only official actions pinned to immutable commit SHAs. grep -Fq 'permissions: {}' "$WORKFLOW" -grep -Fq "workflows: ['NexaWrt AX9000 reproducible RAM-test release', 'NexaWrt x86_64 VM release', 'Promote ESXi-accepted VM RC']" "$WORKFLOW" +grep -Fq "workflows: ['NexaWrt AX9000 reproducible RAM-test release', 'NexaWrt x86_64 VM release', 'Promote ESXi-accepted VM RC', 'NexaWrt signed APK package repository']" "$WORKFLOW" grep -Fq 'types: [completed]' "$WORKFLOW" grep -Fq 'schedule:' "$WORKFLOW" grep -Fq "cron: '17 */6 * * *'" "$WORKFLOW" @@ -180,6 +180,7 @@ grep -Fq 'workflow_dispatch:' "$WORKFLOW" grep -Fq "github.repository == 'tifycloud/NexaWrt' &&" "$WORKFLOW" grep -Fq "github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'" "$WORKFLOW" grep -Fq "'Promote ESXi-accepted VM RC'" "$WORKFLOW" +grep -Fq 'refusing to deploy Pages without the locked repository' "$WORKFLOW" grep -Fq "VM_PROMOTION_WORKFLOW" "$GENERATOR" grep -Fq "VM_PROMOTION_WORKFLOW" "$PROOF_VERIFIER" grep -Fq 'ref: refs/heads/main' "$WORKFLOW" diff --git a/tests/test_source_fetch_policy.sh b/tests/test_source_fetch_policy.sh index a085b22..fd34a2f 100755 --- a/tests/test_source_fetch_policy.sh +++ b/tests/test_source_fetch_policy.sh @@ -45,11 +45,16 @@ mkdir -p \ "$POLICY_REPO/manifests" \ "$POLICY_REPO/configs" \ "$POLICY_REPO/files" \ + "$POLICY_REPO/packages" \ "$POLICY_REPO/patches" cp "$ROOT_DIR/scripts/prepare.sh" "$POLICY_REPO/scripts/prepare.sh" cp "$ROOT_DIR/scripts/sanitize-git-environment.sh" "$POLICY_REPO/scripts/sanitize-git-environment.sh" cp "$ROOT_DIR/scripts/git-metadata-policy.sh" "$POLICY_REPO/scripts/git-metadata-policy.sh" cp "$ROOT_DIR/scripts/lock-file-policy.sh" "$POLICY_REPO/scripts/lock-file-policy.sh" +cp "$ROOT_DIR/manifests/package-repository.lock" "$POLICY_REPO/manifests/package-repository.lock" +cp "$ROOT_DIR/manifests/package-repository-public.pem" "$POLICY_REPO/manifests/package-repository-public.pem" +cp "$ROOT_DIR/manifests/package-repository-packages.txt" "$POLICY_REPO/manifests/package-repository-packages.txt" +cp -R "$ROOT_DIR/packages/nexawrt-repository" "$POLICY_REPO/packages/nexawrt-repository" cat > "$POLICY_REPO/manifests/upstream.lock" <