unos-repository

APK repository for unos
git clone git://git.finwo.net/misc/unos-repository
Log | Files | Refs | README

commit e83fb73fcd0f58cd669baefff66b66522c29e7c6
parent 85f966a7c8c66a7335e5b6d2c1b5c969f66a2ad0
Author: finwo <finwo@pm.me>
Date:   Sun, 13 Sep 2026 23:35:59 +0200

Bootstrapping apk repositories

Diffstat:
Amk/README.md | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amk/build.sh | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amk/keymgmt.sh | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amk/pax-tar.c | 399+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amk/repo-index.sh | 42++++++++++++++++++++++++++++++++++++++++++
Amk/sign-key.inc | 21+++++++++++++++++++++
Mpackages/README.md | 3++-
Apackages/unos-keys/files/apk/unos-dev@finwo.dev-096b7b41.rsa.pub | 14++++++++++++++
Apackages/unos-keys/files/unos/unos-dev@finwo.dev-6a2764d4.ed25519.pub | 3+++
Apackages/unos-keys/template | 22++++++++++++++++++++++
Mpackages/unosd/template | 2+-
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.