commit e83fb73fcd0f58cd669baefff66b66522c29e7c6
parent 85f966a7c8c66a7335e5b6d2c1b5c969f66a2ad0
Author: finwo <finwo@pm.me>
Date: Sun, 13 Sep 2026 23:35:59 +0200
Bootstrapping apk repositories
Diffstat:
11 files changed, 926 insertions(+), 2 deletions(-)
diff --git a/mk/README.md b/mk/README.md
@@ -0,0 +1,82 @@
+# mk/
+
+UNOS package driver: `packages/<name>/template` in, signed `.apk` (v2) out.
+The whole of `build/` is generated output. Only `mk/` is source.
+
+## Files
+
+- `build.sh <pkg>` -- the pipeline: source template -> fetch+verify ->
+ extract -> patches -> `do_build`/`do_install` into `DESTDIR` -> emit signed
+ `.apk` into `build/repo/<arch>/`.
+- `repo-index.sh [arch]` -- rebuild `APKINDEX.tar.gz` with host `apk index`,
+ then sign it in place (abuild-sign layout: gzipped sig tar record
+ prepended, RSA/SHA1 over the index bytes).
+- `pax-tar.c` -- tar writer for all three segments (C, std=c99, no
+ dependencies): sorted entries, uid/gid 0, per-file SHA1 in
+ `APK-TOOLS.checksum.SHA1` pax headers in data-segment mode (`--no-checksum`
+ for control/signature). Compiled on first use by the scripts; the binary is
+ output, not source. Host `tar` is used only for extracting upstreams.
+- `sign-key.inc` -- `resolve_sign_key()` sourced by both scripts above.
+ Precedence: `$UNOS_SIGN_KEY` > repo-root `.sign-key` > single key in
+ `~/.unos-keys/`.
+- `keymgmt.sh` -- key management: `new [--type rsa|supercop] [--for apk|unos]
+ <stem>` generates `<stem>-<8 hex>` in `~/.unos-keys/` (rsa: 4096-bit;
+ supercop: ed25519) and stages the public half into
+ `packages/unos-keys/files/<for>/`; `use <name|file>` pins the package
+ signing key by writing its full path to `.sign-key` (never committed; CI
+ sets `UNOS_SIGN_KEY` or writes `.sign-key` itself); `list` shows local keys
+ and marks the active one. apk mandates RSA -- supercop-for-apk is refused.
+
+C and shell only. No Python anywhere, build host included.
+
+## Template contract
+
+Shell fragment, not a script. Variables: `pkgname`, `version`, `revision`
+(default 0), `short_desc`, `maintainer`, `license`, `homepage`, `distfiles`
+(space-separated URLs, empty = no upstream source), `checksum`
+(space-separated sha256, same order -- mandatory when `distfiles` is set),
+`depends`, `provides`, `replaces` (space-separated, may be empty).
+
+Optional phase overrides: `do_build`, `do_install` (default: no-op).
+Environment and helpers provided: `DESTDIR`, `WRKSRC`, `FILESDIR`,
+`vinstall <file> <mode> <targetdir> [name]`, `vmkdir <dir>`, `msg`, `die`.
+
+Control-script pickup: `pre-install`, `post-install`, `pre-deinstall`,
+`post-deinstall`, `pre-upgrade`, `post-upgrade`, `trigger` in `files/` are
+packed as `.<name>` into the control segment when present.
+
+## Keys
+
+`UNOS_SIGN_KEY` selects the RSA key (4096-bit minimum); unset means the
+single `~/.unos-keys/*.rsa` key. Keys never enter git. The public half
+(`<name>.rsa.pub`) is what lands in `/etc/apk/keys` via the `unos-keys`
+package; v2 requires the on-disk filename to equal the key name embedded in
+`.SIGN.RSA.<name>.rsa.pub`, which the driver guarantees by construction.
+
+Key ceremony: private halves live only in `~/.unos-keys/`. Public halves are
+committed by `keymgmt.sh new` under `packages/unos-keys/files/<for>/` (public
+by design) and installed by that package -- `files/apk/*` to `/etc/apk/keys/`,
+`files/unos/*` to `/etc/unos/keys/`. Rotation = generate, commit the new
+`.pub` files, bump the `unos-keys` version.
+
+## Repo layout
+
+`build/repo/<arch>/` holds `*.apk` + signed `APKINDEX.tar.gz`. The arch level
+is mandatory: apk appends `$arch` to the repository URL when fetching the
+index. Current keys (dev estate, rotate before any release):
+
+- RSA/apk: `unos-dev@finwo.dev-096b7b41`
+- ed25519/supercop: `unos-dev@finwo.dev-6a2764d4`
+
+## Format notes (learned against real Alpine packages)
+
+- `.apk` v2 = signed-control gzip member + data gzip member. The signature
+ covers the control gzip bytes; the control tar carries NO end-of-tar
+ markers (only the data segment terminates the archive).
+- `datahash` is plain sha256 hex of `data.tar.gz` -- no `Q1` prefix (that
+ prefix form lives in indexes, where `apk index` computes it itself).
+
+## Host dependencies
+
+sh, cc, curl or wget, tar, gzip, openssl, sha256sum, install, patch,
+apk-tools (validation only: `apk verify`, `apk index`, `--root` installs).
diff --git a/mk/build.sh b/mk/build.sh
@@ -0,0 +1,188 @@
+#!/bin/sh
+# mk/build.sh - UNOS package driver: template -> signed .apk (v2)
+#
+# Usage: ./mk/build.sh <pkgname>
+#
+# Pipeline: source template -> fetch+verify -> extract -> patches ->
+# do_build/do_install into DESTDIR -> emit signed .apk into build/repo/
+#
+# The whole of build/ is generated output (gitignored). Only mk/ is source.
+set -eu
+
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "${HERE}/.." && pwd)
+PKG="${1:?usage: build.sh <pkgname>}"
+
+TEMPLATE="${ROOT}/packages/${PKG}/template"
+[ -f "${TEMPLATE}" ] || { echo "build.sh: no template: ${TEMPLATE}" >&2; exit 1; }
+
+# --- template variables (defaults; the template overrides) ---
+pkgname=
+version=
+revision=0
+short_desc=
+maintainer=
+license=
+homepage=
+distfiles=
+checksum=
+depends=
+provides=
+replaces=
+
+FILESDIR="${ROOT}/packages/${PKG}/files"
+WORK="${ROOT}/build/work/${PKG}"
+SRCDEST="${ROOT}/build/work/sources"
+DESTDIR="${WORK}/dest"
+WRKSRC=
+OUTDIR="${ROOT}/build/repo"
+
+# --- helpers available to templates ---
+msg() { printf '==> %s\n' "$*"; }
+die() { printf 'build.sh: error: %s\n' "$*" >&2; exit 1; }
+
+# vinstall <file> <mode> <targetdir> [name]
+vinstall() {
+ [ $# -ge 3 ] || die "vinstall needs: file mode targetdir [name]"
+ _src=$1; _mode=$2; _dir=$3; _name=${4:-$(basename "$1")}
+ install -D -m "${_mode}" "${_src}" "${DESTDIR}/${_dir}/${_name}"
+}
+vmkdir() { install -d "${DESTDIR}/$1"; }
+
+# shellcheck disable=SC1090
+. "${TEMPLATE}"
+
+[ -n "${pkgname}" ] || die "template sets no pkgname"
+[ -n "${version}" ] || die "template sets no version"
+[ -n "${short_desc}" ] || die "template sets no short_desc"
+
+PKGVER="${version}-r${revision}"
+ARCH="x86_64"
+OUTDIR="${ROOT}/build/repo/${ARCH}"
+
+mkdir -p "${SRCDEST}" "${WORK}" "${DESTDIR}" "${OUTDIR}"
+
+# --- fetch + verify ---
+if [ -n "${distfiles}" ]; then
+ # checksums are space-separated, in the same order as distfiles
+ set -- ${checksum}
+ for url in ${distfiles}; do
+ want=${1:?template has distfiles but no checksum for ${url}}
+ shift
+ f=${SRCDEST}/$(basename "${url}")
+ if [ ! -f "${f}" ]; then
+ msg "fetching $(basename "${url}")"
+ curl -fL -o "${f}" "${url}" || wget -O "${f}" "${url}"
+ fi
+ echo "${want} ${f}" | sha256sum -c - || die "checksum mismatch: ${f}"
+ done
+fi
+
+# --- extract (first tarball) + patches ---
+if [ -n "${distfiles}" ]; then
+ set -- ${distfiles}
+ mkdir -p "${WORK}/src"
+ tar -xf "${SRCDEST}/$(basename "$1")" -C "${WORK}/src"
+ # WRKSRC: single top-level dir if the tarball has exactly one, else src/
+ n=$(ls -A "${WORK}/src" | wc -l)
+ if [ "${n}" = "1" ] && [ -d "${WORK}/src/$(ls -A "${WORK}/src")" ]; then
+ WRKSRC=${WORK}/src/$(ls -A "${WORK}/src")
+ else
+ WRKSRC=${WORK}/src
+ fi
+ for p in "${ROOT}/packages/${PKG}"/patches/*.patch; do
+ [ -e "${p}" ] || break
+ msg "applying $(basename "${p}")"
+ patch -d "${WRKSRC}" -p1 --no-backup-if-mismatch -i "${p}"
+ done
+fi
+export DESTDIR WRKSRC FILESDIR
+
+# --- build + install phases (defaults: no-op) ---
+command -v do_build >/dev/null 2>&1 || do_build() { :; }
+command -v do_install >/dev/null 2>&1 || do_install() { :; }
+msg "${pkgname}: do_build"
+do_build
+msg "${pkgname}: do_install"
+do_install
+
+# --- signing key: UNOS_SIGN_KEY > .sign-key > single key in ~/.unos-keys ---
+# shellcheck disable=SC1090
+. "${HERE}/sign-key.inc"
+KEY=$(resolve_sign_key) || die "no signing key (mk/keymgmt.sh use)"
+
+# --- emit signed .apk (v2): shell + pax-tar + openssl, no other languages ---
+msg "${pkgname}: packing ${PKGVER}"
+
+# fixed timestamp for the whole artifact (reproducible when pinned)
+: "${SOURCE_DATE_EPOCH:=$(date +%s)}"
+export SOURCE_DATE_EPOCH
+
+# helper binary (C source is committed; the binary is output)
+if [ ! -x "${HERE}/pax-tar" ] || [ "${HERE}/pax-tar.c" -nt "${HERE}/pax-tar" ]; then
+ msg "building pax-tar"
+ cc -std=c99 -O2 -Wall -Wextra -o "${HERE}/pax-tar" "${HERE}/pax-tar.c"
+fi
+
+CTRL="${WORK}/ctrl"
+rm -rf "${CTRL}"
+mkdir -p "${CTRL}"
+
+{
+ printf 'pkgname = %s\n' "${pkgname}"
+ printf 'pkgver = %s\n' "${PKGVER}"
+ printf 'pkgdesc = %s\n' "${short_desc}"
+ printf 'url = %s\n' "${homepage}"
+ printf 'builddate = %s\n' "${SOURCE_DATE_EPOCH}"
+ printf 'packager = %s\n' "UNOS build driver (mk/build.sh)"
+ printf 'size = %s\n' "$(find "${DESTDIR}" -type f -printf '%s\n' | awk '{s+=$1} END {print s+0}')"
+ printf 'arch = %s\n' "x86_64"
+ printf 'origin = %s\n' "${pkgname}"
+ printf 'maintainer = %s\n' "${maintainer}"
+ printf 'license = %s\n' "${license}"
+ for d in ${depends}; do printf 'depend = %s\n' "${d}"; done
+ for p in ${provides}; do printf 'provides = %s\n' "${p}"; done
+ for r in ${replaces}; do printf 'replaces = %s\n' "${r}"; done
+} > "${CTRL}/.PKGINFO"
+
+for s in pre-install post-install pre-deinstall post-deinstall pre-upgrade post-upgrade trigger; do
+ if [ -f "${FILESDIR}/${s}" ]; then
+ cp "${FILESDIR}/${s}" "${CTRL}/.${s}"
+ chmod 755 "${CTRL}/.${s}"
+ fi
+done
+
+# data segment (pax-tar: sorted, root-owned, per-file SHA1 headers).
+# The data tar KEEPS its end-of-tar markers: it terminates the archive.
+"${HERE}/pax-tar" "${DESTDIR}" "${WORK}/data.tar"
+gzip -n -9 -f "${WORK}/data.tar"
+datahash=$(sha256sum "${WORK}/data.tar.gz" | cut -d' ' -f1)
+printf 'datahash = %s\n' "${datahash}" >> "${CTRL}/.PKGINFO"
+
+# control segment (pax-tar without checksum records; host tar not trusted).
+# The control tar must NOT carry end-of-tar markers: gunzipped, the package
+# is one continuous tar stream (sig record, control files, data files) and
+# only the data segment terminates it. This is the abuild-sign layout.
+"${HERE}/pax-tar" --no-checksum "${CTRL}" "${WORK}/control.tar"
+ctlsiz=$(stat -c%s "${WORK}/control.tar")
+head -c $((ctlsiz - 1024)) "${WORK}/control.tar" > "${WORK}/controlnotr.tar"
+gzip -n -9 -f "${WORK}/controlnotr.tar"
+mv "${WORK}/controlnotr.tar.gz" "${WORK}/control.tar.gz"
+
+# signature segment: RSA/SHA1 over the control gzip stream bytes, single tar
+# record with NO end-of-tar blocks, prepended to control.tar.gz in place
+# (abuild-sign layout: .apk = signed-control + data = 3 gzip members).
+KEYNAME=$(basename "${KEY}" .rsa)
+openssl dgst -sha1 -sign "${KEY}" -out "${WORK}/sig.bin" "${WORK}/control.tar.gz"
+mkdir -p "${WORK}/sigdir"
+cp "${WORK}/sig.bin" "${WORK}/sigdir/.SIGN.RSA.${KEYNAME}.rsa.pub"
+"${HERE}/pax-tar" --no-checksum "${WORK}/sigdir" "${WORK}/sig.tar"
+sigsiz=$(stat -c%s "${WORK}/sig.tar")
+head -c $((sigsiz - 1024)) "${WORK}/sig.tar" > "${WORK}/signo.tar"
+gzip -n -9 -f "${WORK}/signo.tar"
+cat "${WORK}/signo.tar.gz" "${WORK}/control.tar.gz" > "${WORK}/signed-control.tar.gz"
+
+cat "${WORK}/signed-control.tar.gz" "${WORK}/data.tar.gz" \
+ > "${OUTDIR}/${pkgname}-${PKGVER}.apk"
+
+msg "done: ${OUTDIR}/${pkgname}-${PKGVER}.apk"
diff --git a/mk/keymgmt.sh b/mk/keymgmt.sh
@@ -0,0 +1,152 @@
+#!/bin/sh
+# mk/keymgmt.sh - signing key management.
+#
+# keymgmt.sh new [--type rsa|supercop] [--for apk|unos] <stem>
+# Generate a key named <stem>-<8 hex> in ~/.unos-keys/ and stage its
+# public half into packages/unos-keys/files/<for>/. Defaults: rsa for
+# apk (4096-bit), supercop for unos. apk mandates RSA: supercop-for-apk
+# is refused. After adding keys, bump the unos-keys version and rebuild.
+#
+# keymgmt.sh use <name|file>
+# Select the RSA key used to sign packages; writes its full path to
+# .sign-key (repo root, never committed). CI sets UNOS_SIGN_KEY or
+# writes .sign-key itself. Accepts a stem, a filename in ~/.unos-keys/,
+# or a path.
+#
+# keymgmt.sh list
+# Show local keys and which one is active.
+set -eu
+
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "${HERE}/.." && pwd)
+KEYDIR="${HOME}/.unos-keys"
+PKGKEYS="${ROOT}/packages/unos-keys/files"
+
+msg() { printf '==> %s\n' "$*"; }
+die() { printf 'keymgmt.sh: error: %s\n' "$*" >&2; exit 1; }
+
+valid_stem() {
+ case "$1" in
+ ''|*[!A-Za-z0-9@._-]*) return 1;;
+ *) return 0;;
+ esac
+}
+
+cmd_new() {
+ type=rsa
+ for=
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --type) type=$2; shift 2;;
+ --for) for=$2; shift 2;;
+ -h|--help) cmd_usage; exit 0;;
+ --*) die "unknown flag: $1";;
+ *) break;;
+ esac
+ done
+ [ $# = 1 ] || die "usage: keymgmt.sh new [--type rsa|supercop] [--for apk|unos] <stem>"
+ stem=$1
+ valid_stem "${stem}" || die "bad stem (allowed: A–Z a–z 0–9 @ . _ -): ${stem}"
+ case "${type}" in rsa|supercop) ;; *) die "type must be rsa or supercop";; esac
+ if [ -z "${for}" ]; then
+ if [ "${type}" = rsa ]; then for=apk; else for=unos; fi
+ fi
+ case "${for}" in apk|unos) ;; *) die "--for must be apk or unos";; esac
+ if [ "${for}" = apk ] && [ "${type}" != rsa ]; then
+ die "apk signatures mandate RSA; supercop keys are unos-only"
+ fi
+
+ mkdir -p "${KEYDIR}"
+ chmod 700 "${KEYDIR}"
+ i=0
+ while :; do
+ suffix=$(openssl rand -hex 4)
+ base="${stem}-${suffix}"
+ case "${type}" in
+ rsa) priv="${base}.rsa"; pub="${base}.rsa.pub";;
+ supercop) priv="${base}.ed25519"; pub="${base}.ed25519.pub";;
+ esac
+ [ -e "${KEYDIR}/${priv}" ] || [ -e "${KEYDIR}/${pub}" ] || break
+ i=$((i + 1))
+ [ "${i}" -lt 10 ] || die "cannot find a free key name, retry"
+ done
+
+ case "${type}" in
+ rsa)
+ openssl genrsa -out "${KEYDIR}/${priv}" 4096 2>/dev/null
+ openssl rsa -in "${KEYDIR}/${priv}" -pubout -out "${KEYDIR}/${pub}" 2>/dev/null
+ ;;
+ supercop)
+ supercop generate -k "${KEYDIR}/${priv}"
+ supercop printkey -k "${KEYDIR}/${priv}" --public-only -f asc -o "${KEYDIR}/${pub}"
+ ;;
+ esac
+ chmod 600 "${KEYDIR}/${priv}"
+
+ mkdir -p "${PKGKEYS}/${for}"
+ cp "${KEYDIR}/${pub}" "${PKGKEYS}/${for}/${pub}"
+ msg "key: ${KEYDIR}/${priv}"
+ msg "pub staged: packages/unos-keys/files/${for}/${pub}"
+ echo "next: bump packages/unos-keys version, rebuild it, re-index"
+}
+
+cmd_use() {
+ [ $# = 1 ] || die "usage: keymgmt.sh use <name|file>"
+ arg=$1
+ if [ -f "${arg}" ]; then
+ case "${arg}" in
+ /*) path=${arg};;
+ *) path=${PWD}/${arg};;
+ esac
+ elif [ -f "${KEYDIR}/${arg}" ]; then
+ path=${KEYDIR}/${arg}
+ elif [ -f "${KEYDIR}/${arg}.rsa" ]; then
+ path=${KEYDIR}/${arg}.rsa
+ else
+ die "no such key: ${arg}"
+ fi
+ openssl rsa -in "${path}" -check -noout >/dev/null 2>&1 \
+ || die "not an RSA private key: ${path}"
+ printf '%s\n' "${path}" > "${ROOT}/.sign-key"
+ msg "signing key: ${path}"
+}
+
+cmd_list() {
+ # shellcheck disable=SC1090
+ . "${HERE}/sign-key.inc"
+ active=$(resolve_sign_key 2>/dev/null) || active=
+ found=0
+ for f in "${KEYDIR}"/*; do
+ [ -e "${f}" ] || continue
+ found=1
+ mark=" "
+ [ "${f}" = "${active}" ] && mark="*"
+ case "${f}" in
+ *.rsa) kind="rsa";;
+ *.rsa.pub) kind="rsa-pub";;
+ *.ed25519) kind="ed25519";;
+ *.ed25519.pub) kind="ed25519-pub";;
+ *) kind="?";;
+ esac
+ printf '%s %-12s %s\n' "${mark}" "${kind}" "${f}"
+ done
+ [ "${found}" = 1 ] || echo "(no keys in ${KEYDIR})"
+}
+
+cmd_usage() {
+ cat <<EOF
+usage: keymgmt.sh new [--type rsa|supercop] [--for apk|unos] <stem>
+ keymgmt.sh use <name|file>
+ keymgmt.sh list
+EOF
+}
+
+[ $# -ge 1 ] || { cmd_usage >&2; exit 1; }
+cmd=$1; shift
+case "${cmd}" in
+ new) cmd_new "$@";;
+ use) cmd_use "$@";;
+ list) cmd_list "$@";;
+ -h|--help) cmd_usage; exit 0;;
+ *) cmd_usage >&2; exit 1;;
+esac
diff --git a/mk/pax-tar.c b/mk/pax-tar.c
@@ -0,0 +1,399 @@
+/* mk/pax-tar.c - data-tar writer for the UNOS apk driver.
+ *
+ * Usage: pax-tar [--no-checksum] <stagedir> <out.tar>
+ *
+ * Writes a deterministic tar of <stagedir>: entries sorted by name,
+ * uid/gid 0, user/group root. Default mode adds a per-file SHA1 in an
+ * `APK-TOOLS.checksum.SHA1` pax extended-header record ahead of every
+ * regular file (the apk v2 data-segment layout `abuild-tar` produces);
+ * `--no-checksum` skips those records (control and signature segments).
+ *
+ * Only regular files, directories and symlinks are accepted; anything else
+ * is a loud error. SHA1 is implemented below from FIPS 180-4 (no crypto
+ * dependency); its output is verified against the system sha1sum during
+ * driver testing. File mtimes come from SOURCE_DATE_EPOCH when set, else
+ * from the filesystem.
+ */
+#define _POSIX_C_SOURCE 200809L
+#include <dirent.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+/* ---------------- SHA1 (FIPS 180-4) ---------------- */
+
+typedef struct {
+ uint32_t h[5];
+ uint64_t len; /* total message length in bytes */
+ uint8_t buf[64];
+ size_t buflen;
+} sha1_t;
+
+static uint32_t rol(uint32_t v, int n) { return (v << n) | (v >> (32 - n)); }
+
+static void sha1_block(sha1_t *c, const uint8_t *p) {
+ uint32_t w[80];
+ for (int i = 0; i < 16; i++)
+ w[i] = ((uint32_t)p[4 * i] << 24) | ((uint32_t)p[4 * i + 1] << 16) |
+ ((uint32_t)p[4 * i + 2] << 8) | p[4 * i + 3];
+ for (int i = 16; i < 80; i++)
+ w[i] = rol(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
+ uint32_t a = c->h[0], b = c->h[1], v = c->h[2], d = c->h[3], e = c->h[4];
+ for (int i = 0; i < 80; i++) {
+ uint32_t f, k;
+ if (i < 20) {
+ f = (b & v) | (~b & d);
+ k = 0x5a827999;
+ } else if (i < 40) {
+ f = b ^ v ^ d;
+ k = 0x6ed9eba1;
+ } else if (i < 60) {
+ f = (b & v) | (b & d) | (v & d);
+ k = 0x8f1bbcdc;
+ } else {
+ f = b ^ v ^ d;
+ k = 0xca62c1d6;
+ }
+ uint32_t t = rol(a, 5) + f + e + k + w[i];
+ e = d;
+ d = v;
+ v = rol(b, 30);
+ b = a;
+ a = t;
+ }
+ c->h[0] += a;
+ c->h[1] += b;
+ c->h[2] += v;
+ c->h[3] += d;
+ c->h[4] += e;
+}
+
+static void sha1_init(sha1_t *c) {
+ c->h[0] = 0x67452301;
+ c->h[1] = 0xefcdab89;
+ c->h[2] = 0x98badcfe;
+ c->h[3] = 0x10325476;
+ c->h[4] = 0xc3d2e1f0;
+ c->len = 0;
+ c->buflen = 0;
+}
+
+static void sha1_update(sha1_t *c, const uint8_t *data, size_t n) {
+ c->len += n;
+ while (n > 0) {
+ size_t take = 64 - c->buflen;
+ if (take > n)
+ take = n;
+ memcpy(c->buf + c->buflen, data, take);
+ c->buflen += take;
+ data += take;
+ n -= take;
+ if (c->buflen == 64) {
+ sha1_block(c, c->buf);
+ c->buflen = 0;
+ }
+ }
+}
+
+static void sha1_final(sha1_t *c, uint8_t out[20]) {
+ uint64_t bits = c->len * 8;
+ uint8_t one = 0x80;
+ sha1_update(c, &one, 1);
+ uint8_t zero = 0;
+ while (c->buflen != 56)
+ sha1_update(c, &zero, 1);
+ uint8_t lenbuf[8];
+ for (int i = 0; i < 8; i++)
+ lenbuf[i] = (uint8_t)(bits >> (56 - 8 * i));
+ /* feed length directly: buffer is at 56, one block completes it */
+ memcpy(c->buf + 56, lenbuf, 8);
+ sha1_block(c, c->buf);
+ for (int i = 0; i < 5; i++) {
+ out[4 * i] = (uint8_t)(c->h[i] >> 24);
+ out[4 * i + 1] = (uint8_t)(c->h[i] >> 16);
+ out[4 * i + 2] = (uint8_t)(c->h[i] >> 8);
+ out[4 * i + 3] = (uint8_t)c->h[i];
+ }
+}
+
+static void sha1_file_hex(const char *path, char out[41]) {
+ FILE *f = fopen(path, "rb");
+ if (!f) {
+ perror(path);
+ exit(1);
+ }
+ sha1_t c;
+ sha1_init(&c);
+ uint8_t buf[65536];
+ size_t n;
+ while ((n = fread(buf, 1, sizeof buf, f)) > 0)
+ sha1_update(&c, buf, n);
+ if (ferror(f)) {
+ perror(path);
+ exit(1);
+ }
+ fclose(f);
+ uint8_t digest[20];
+ sha1_final(&c, digest);
+ for (int i = 0; i < 20; i++)
+ sprintf(out + 2 * i, "%02x", digest[i]);
+ out[40] = '\0';
+}
+
+/* ---------------- tar writer ---------------- */
+
+typedef struct {
+ char *arc; /* archive name, no leading ./ */
+ char *full; /* filesystem path */
+ struct stat st;
+} entry_t;
+
+static entry_t *entries;
+static size_t nentries, capentries;
+
+static void push_entry(const char *arc, const char *full, struct stat *st) {
+ if (nentries == capentries) {
+ capentries = capentries ? capentries * 2 : 256;
+ entries = realloc(entries, capentries * sizeof *entries);
+ if (!entries) {
+ perror("realloc");
+ exit(1);
+ }
+ }
+ entries[nentries].arc = strdup(arc);
+ entries[nentries].full = strdup(full);
+ entries[nentries].st = *st;
+ nentries++;
+}
+
+static void walk(const char *full, const char *arc) {
+ DIR *d = opendir(full);
+ if (!d) {
+ perror(full);
+ exit(1);
+ }
+ struct dirent *de;
+ while ((de = readdir(d))) {
+ if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, ".."))
+ continue;
+ char sub_full[8192], sub_arc[8192];
+ snprintf(sub_full, sizeof sub_full, "%s/%s", full, de->d_name);
+ if (arc[0])
+ snprintf(sub_arc, sizeof sub_arc, "%s/%s", arc, de->d_name);
+ else
+ snprintf(sub_arc, sizeof sub_arc, "%s", de->d_name);
+ struct stat st;
+ if (lstat(sub_full, &st) != 0) {
+ perror(sub_full);
+ exit(1);
+ }
+ if (S_ISDIR(st.st_mode))
+ walk(sub_full, sub_arc);
+ push_entry(sub_arc, sub_full, &st);
+ }
+ closedir(d);
+}
+
+static int cmp_entry(const void *a, const void *b) {
+ return strcmp(((const entry_t *)a)->arc, ((const entry_t *)b)->arc);
+}
+
+static void put_oct(char *dst, size_t w, uint64_t v) {
+ /* w includes the trailing NUL; value left-padded with '0' */
+ dst[w - 1] = '\0';
+ for (size_t i = w - 1; i > 0;) {
+ i--;
+ dst[i] = (char)('0' + (v & 7));
+ v >>= 3;
+ }
+}
+
+static void write_zeros(FILE *out, size_t n) {
+ static const char z[512];
+ while (n > 0) {
+ size_t take = n > sizeof z ? sizeof z : n;
+ if (fwrite(z, 1, take, out) != take) {
+ perror("fwrite");
+ exit(1);
+ }
+ n -= take;
+ }
+}
+
+/* Split name into ustar name/prefix at a '/' boundary. Dies if impossible. */
+static void split_name(const char *arc, char *name, char *prefix) {
+ if (strlen(arc) <= 100) {
+ strcpy(name, arc);
+ prefix[0] = '\0';
+ return;
+ }
+ size_t len = strlen(arc);
+ /* latest '/' that leaves <=155 prefix bytes and <=100 name bytes */
+ size_t cut = 0;
+ for (size_t i = 0; i < len; i++) {
+ if (arc[i] == '/' && i <= 155 && len - (i + 1) <= 100)
+ cut = i;
+ }
+ if (!cut) {
+ fprintf(stderr, "pax-tar: name too long: %s\n", arc);
+ exit(1);
+ }
+ memcpy(prefix, arc, cut);
+ prefix[cut] = '\0';
+ strcpy(name, arc + cut + 1);
+}
+
+static void write_header(FILE *out, const char *arc, mode_t mode,
+ uint64_t size, uint64_t mtime, char type,
+ const char *linkname) {
+ char blk[512];
+ char name[100], prefix[155];
+ memset(blk, 0, sizeof blk);
+ split_name(arc, name, prefix);
+ memcpy(blk + 0, name, strlen(name));
+ put_oct(blk + 100, 8, (uint64_t)mode & 07777);
+ put_oct(blk + 108, 8, 0);
+ put_oct(blk + 116, 8, 0);
+ put_oct(blk + 124, 12, size);
+ put_oct(blk + 136, 12, mtime);
+ memset(blk + 148, ' ', 8);
+ blk[156] = type;
+ if (linkname)
+ memcpy(blk + 157, linkname, strlen(linkname));
+ memcpy(blk + 257, "ustar", 5);
+ blk[262] = '\0';
+ memcpy(blk + 263, "00", 2);
+ memcpy(blk + 265, "root", 4);
+ memcpy(blk + 297, "root", 4);
+ memcpy(blk + 345, prefix, strlen(prefix));
+ unsigned sum = 0;
+ for (int i = 0; i < 512; i++)
+ sum += (unsigned char)blk[i];
+ char chk[8];
+ snprintf(chk, sizeof chk, "%06o", sum);
+ memcpy(blk + 148, chk, 6);
+ blk[154] = '\0';
+ blk[155] = ' ';
+ if (fwrite(blk, 1, sizeof blk, out) != sizeof blk) {
+ perror("fwrite");
+ exit(1);
+ }
+}
+
+/* pax extended-header record: "<len> <keyword>=<value>\n" */
+static void write_pax_record(FILE *out, const char *keyword,
+ const char *value, uint64_t mtime) {
+ size_t kv = strlen(keyword) + 1 + strlen(value) + 1; /* "k=v\n" */
+ size_t len = kv + 2; /* first guess for digits + space */
+ char digits[32];
+ for (;;) {
+ snprintf(digits, sizeof digits, "%zu", len);
+ size_t need = kv + strlen(digits) + 1;
+ if (need == len)
+ break;
+ len = need;
+ }
+ char *rec = malloc(len + 1);
+ snprintf(rec, len + 1, "%s %s=%s\n", digits, keyword, value);
+ /* extended-header header block uses typeflag 'x'; name content-free */
+ write_header(out, "pax-header", 0, len, mtime, 'x', NULL);
+ if (fwrite(rec, 1, len, out) != len) {
+ perror("fwrite");
+ exit(1);
+ }
+ free(rec);
+ write_zeros(out, (512 - (len % 512)) % 512);
+}
+
+static uint64_t now_or_epoch(void) {
+ const char *e = getenv("SOURCE_DATE_EPOCH");
+ if (e && e[0])
+ return (uint64_t)atoll(e);
+ return 0; /* 0 = keep each file's own mtime */
+}
+
+int main(int argc, char **argv) {
+ int checksums = 1;
+ if (argc == 4 && !strcmp(argv[1], "--no-checksum")) {
+ checksums = 0;
+ argv++;
+ argc--;
+ }
+ if (argc != 3) {
+ fprintf(stderr, "usage: pax-tar [--no-checksum] <stagedir> <out.tar>\n");
+ return 1;
+ }
+ uint64_t epoch = now_or_epoch();
+ walk(argv[1], "");
+ qsort(entries, nentries, sizeof *entries, cmp_entry);
+
+ FILE *out = fopen(argv[2], "wb");
+ if (!out) {
+ perror(argv[2]);
+ return 1;
+ }
+ for (size_t i = 0; i < nentries; i++) {
+ entry_t *e = &entries[i];
+ uint64_t mtime =
+ epoch ? epoch : (uint64_t)e->st.st_mtime;
+ if (S_ISDIR(e->st.st_mode)) {
+ char *slash = malloc(strlen(e->arc) + 2);
+ sprintf(slash, "%s/", e->arc);
+ write_header(out, slash, e->st.st_mode, 0, mtime, '5', NULL);
+ free(slash);
+ } else if (S_ISREG(e->st.st_mode)) {
+ char hex[41];
+ uint64_t fsize = (uint64_t)e->st.st_size;
+ if (checksums) {
+ sha1_file_hex(e->full, hex);
+ write_pax_record(out, "APK-TOOLS.checksum.SHA1", hex, mtime);
+ }
+ write_header(out, e->arc, e->st.st_mode, fsize,
+ mtime, '0', NULL);
+ FILE *f = fopen(e->full, "rb");
+ if (!f) {
+ perror(e->full);
+ return 1;
+ }
+ char buf[65536];
+ size_t n;
+ uint64_t left = fsize;
+ while (left > 0 &&
+ (n = fread(buf, 1, left > sizeof buf ? sizeof buf : left, f)) >
+ 0) {
+ if (fwrite(buf, 1, n, out) != n) {
+ perror("fwrite");
+ return 1;
+ }
+ left -= n;
+ }
+ fclose(f);
+ if (left != 0) {
+ fprintf(stderr, "pax-tar: short read: %s\n", e->full);
+ return 1;
+ }
+ write_zeros(out, (512 - (fsize % 512)) % 512);
+ } else if (S_ISLNK(e->st.st_mode)) {
+ char target[1024];
+ ssize_t n = readlink(e->full, target, sizeof target - 1);
+ if (n < 0) {
+ perror(e->full);
+ return 1;
+ }
+ target[n] = '\0';
+ write_header(out, e->arc, e->st.st_mode, 0, mtime, '2', target);
+ } else {
+ fprintf(stderr, "pax-tar: unsupported file type: %s\n", e->full);
+ return 1;
+ }
+ }
+ write_zeros(out, 1024); /* end-of-tar */
+ if (fclose(out) != 0) {
+ perror("fclose");
+ return 1;
+ }
+ return 0;
+}
diff --git a/mk/repo-index.sh b/mk/repo-index.sh
@@ -0,0 +1,42 @@
+#!/bin/sh
+# mk/repo-index.sh - (re)build and sign APKINDEX.tar.gz for an arch repo dir
+#
+# Usage: ./mk/repo-index.sh [arch]
+#
+# Index generation uses host `apk index`; signing follows the abuild-sign
+# layout (gzipped sig tar record prepended to the index, RSA/SHA1 over the
+# index bytes) using only our own tooling.
+set -eu
+
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "${HERE}/.." && pwd)
+ARCH="${1:-x86_64}"
+REPODIR="${ROOT}/build/repo/${ARCH}"
+
+[ -d "${REPODIR}" ] || { echo "repo-index.sh: no such dir: ${REPODIR}" >&2; exit 1; }
+
+. "${HERE}/sign-key.inc"
+KEY=$(resolve_sign_key) || { echo "repo-index.sh: no signing key (mk/keymgmt.sh use)" >&2; exit 1; }
+KEYNAME=$(basename "${KEY}" .rsa)
+
+if [ ! -x "${HERE}/pax-tar" ] || [ "${HERE}/pax-tar.c" -nt "${HERE}/pax-tar" ]; then
+ cc -std=c99 -O2 -Wall -Wextra -o "${HERE}/pax-tar" "${HERE}/pax-tar.c"
+fi
+
+cd "${REPODIR}"
+echo "==> indexing ${REPODIR}"
+apk index --allow-untrusted -o APKINDEX.tar.gz *.apk
+
+echo "==> signing APKINDEX.tar.gz with ${KEYNAME}"
+openssl dgst -sha1 -sign "${KEY}" -out ".SIGN.RSA.${KEYNAME}.rsa.pub" APKINDEX.tar.gz
+mkdir -p "${ROOT}/build/work/.idxsig"
+cp ".SIGN.RSA.${KEYNAME}.rsa.pub" "${ROOT}/build/work/.idxsig/"
+"${HERE}/pax-tar" --no-checksum "${ROOT}/build/work/.idxsig" idxsig.tar
+sigsiz=$(stat -c%s idxsig.tar)
+head -c $((sigsiz - 1024)) idxsig.tar > idxsignotr.tar
+gzip -n -9 -f idxsignotr.tar
+cat idxsignotr.tar.gz APKINDEX.tar.gz > APKINDEX.signed.tar.gz
+mv APKINDEX.signed.tar.gz APKINDEX.tar.gz
+rm -f ".SIGN.RSA.${KEYNAME}.rsa.pub" idxsig.tar idxsignotr.tar.gz
+rm -rf "${ROOT}/build/work/.idxsig"
+echo "==> done: ${REPODIR}/APKINDEX.tar.gz"
diff --git a/mk/sign-key.inc b/mk/sign-key.inc
@@ -0,0 +1,21 @@
+# mk/sign-key.inc - resolve_sign_key(): print the RSA package-signing key.
+#
+# Precedence: $UNOS_SIGN_KEY > $ROOT/.sign-key > single key in ~/.unos-keys/.
+# Source this file ($ROOT must be set), then: KEY=$(resolve_sign_key).
+resolve_sign_key() {
+ _k="${UNOS_SIGN_KEY:-}"
+ if [ -z "${_k}" ] && [ -n "${ROOT:-}" ] && [ -f "${ROOT}/.sign-key" ]; then
+ _k=$(cat "${ROOT}/.sign-key")
+ fi
+ if [ -z "${_k}" ]; then
+ set -- ~/.unos-keys/*.rsa
+ if [ $# = 1 ] && [ -f "$1" ]; then
+ _k=$1
+ else
+ echo "sign-key: UNOS_SIGN_KEY unset, no ${ROOT}/.sign-key, no single key in ~/.unos-keys (see mk/keymgmt.sh)" >&2
+ return 1
+ fi
+ fi
+ [ -f "${_k}" ] || { echo "sign-key: key not found: ${_k}" >&2; return 1; }
+ printf '%s\n' "${_k}"
+}
diff --git a/packages/README.md b/packages/README.md
@@ -29,7 +29,8 @@ distfiles="https://example.org/example-${version}.tar.gz"
checksum=<sha256>
```
-Templates are built by CI into a signed xbps repository. Nothing in this
+Templates are built by our own driver (local now, CI later) into a signed
+apk repository (v2 artifacts, apk-tools v3 manager). Nothing in this
directory is ever built by hand on a target switch.
## Rules
diff --git a/packages/unos-keys/files/apk/unos-dev@finwo.dev-096b7b41.rsa.pub b/packages/unos-keys/files/apk/unos-dev@finwo.dev-096b7b41.rsa.pub
@@ -0,0 +1,14 @@
+-----BEGIN PUBLIC KEY-----
+MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA5qfmNtbXCwm8tJU1LqZj
+vrXmrduGKHkklblJq9IBJJd71eVvdcnl/nWeV82z1oa6gOfr/Uzk3w+HRdlvjNFQ
+P8M8vekJ0q2JfdpIu5cz4ESMJVRplW/G4PseJqho8bcLif7q/SM43f+u8Zr1qsmH
+oIhyyoN/E75ut6lZ9LSXZlkmAE+8K3aWjAKx+WbrGFiFevirL+8ZDrrZEfRkVQ9Y
+6RInXWOEbr3pE+OoeGazGJJcYwB84okZNei8XV06bQ8lV4uvS+nf78tp+tk1LaAM
+DW7KiHgGJmUr4Jog4gOC308cJQrzVE7u8waL9FLaanzj8yCKYJk1ynuaLJKvxm6I
+h7z9sTHUt0XTWo2ycWW2k0i+3HO85sdfkYaQyPxr261d72C+FAfYd4dixzHwBohc
+m7LOKILnEzUAMXGsqp8qoPxu3iNoJCUXPV+yenxqgaGLMvir+L8HdIYjjJPn5vWM
+izgctaaqXs63KQHF9/8pF/eNx1cb85O/Or9y7T5yG8JKk4w+9TX6hpgOqjtb+oDJ
+oiwlKvXwfZEbDdDAwR5PQQiddWhrhiNNZ9rXJlNdBeGKfB+xfiAR/yshzq46e+0f
+TxaNxocgzpMTABixAk9HcNhRSOzij8ACr8OoouQefAjKIcjAajoMc9bnCAf61pVN
+loZYJqtK1oDcmLq1tsD78vUCAwEAAQ==
+-----END PUBLIC KEY-----
diff --git a/packages/unos-keys/files/unos/unos-dev@finwo.dev-6a2764d4.ed25519.pub b/packages/unos-keys/files/unos/unos-dev@finwo.dev-6a2764d4.ed25519.pub
@@ -0,0 +1,3 @@
+-----BEGIN SUPERCOP PUBLIC KEY-----
+EDuyIGtEp/bOPn10kPtY3nOQ0bAo+qmGk388ShA1DGc=
+-----END SUPERCOP PUBLIC KEY-----
diff --git a/packages/unos-keys/template b/packages/unos-keys/template
@@ -0,0 +1,22 @@
+# Template file for 'unos-keys'
+pkgname=unos-keys
+version=0.1.0
+revision=1
+short_desc="UNOS trust anchors - apk and installer verification keys"
+maintainer="finwo <finwo@pm.me>"
+license="GPL-2.0-only"
+homepage="https://unos.finwo.dev"
+# Key ceremony: private halves live in ~/.unos-keys/ and never enter git.
+# Public halves are committed here by mk/keymgmt.sh (public by design):
+# files/apk/* -> /etc/apk/keys/, files/unos/* -> /etc/unos/keys/.
+# Rotation = add the new .pub files and bump version.
+do_install() {
+ for k in "${FILESDIR}"/apk/*; do
+ [ -e "${k}" ] || break
+ vinstall "${k}" 644 etc/apk/keys "$(basename "${k}")"
+ done
+ for k in "${FILESDIR}"/unos/*; do
+ [ -e "${k}" ] || break
+ vinstall "${k}" 644 etc/unos/keys "$(basename "${k}")"
+ done
+}
diff --git a/packages/unosd/template b/packages/unosd/template
@@ -6,7 +6,7 @@ short_desc="UNOS control plane daemon - kernel netlink mirror into the dataplane
maintainer="finwo <finwo@pm.me>"
license="GPL-2.0-only"
homepage="https://unos.finwo.dev"
-depends="runit"
+depends="busybox"
do_install() {
# TODO(phase0): wire the src/unosd build into CI so the binary lands here.