diff --git a/.gitea/workflows/acceptance.yml b/.gitea/workflows/acceptance.yml index 592adb28..8efd9761 100644 --- a/.gitea/workflows/acceptance.yml +++ b/.gitea/workflows/acceptance.yml @@ -37,6 +37,18 @@ jobs: fetch-depth: 0 # the SBOM's version is git describe lfs: false # only the logs and DoE CSVs are in LFS; the build needs neither + - name: The capsule-signing key + env: + KEY: ${{ secrets.CAPSULE_SIGN_KEY }} + run: | # ci/pki/make-ca.sh; kernel/Makefile signs with SIGN_KEY + if [ -n "$KEY" ]; then + f="${RUNNER_TEMP:-/tmp}/capsule-signing.key" + (umask 077; printf '%s\n' "$KEY" > "$f") + echo "SIGN_KEY=$f" >> "$GITHUB_ENV"; echo "The capsules will be signed." + else + echo "::warning::No CAPSULE_SIGN_KEY secret: the capsules are built unsigned." + fi + - name: Acceptance amd64 run: scripts/nightly.sh accept amd64 - name: Acceptance aarch64 diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 1b8fc6aa..88f8c199 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -34,6 +34,18 @@ jobs: fetch-depth: 0 # the SBOM's version is git describe lfs: false # only the logs and DoE CSVs are in LFS; the build needs neither + - name: The capsule-signing key + env: + KEY: ${{ secrets.CAPSULE_SIGN_KEY }} + run: | # ci/pki/make-ca.sh; kernel/Makefile signs with SIGN_KEY + if [ -n "$KEY" ]; then + f="${RUNNER_TEMP:-/tmp}/capsule-signing.key" + (umask 077; printf '%s\n' "$KEY" > "$f") + echo "SIGN_KEY=$f" >> "$GITHUB_ENV"; echo "The capsules will be signed." + else + echo "::warning::No CAPSULE_SIGN_KEY secret: the capsules are built unsigned." + fi + - name: Boot image run: make boot_image TARGET=${{ matrix.board }} @@ -62,5 +74,17 @@ jobs: with: lfs: false + - name: The capsule-signing key + env: + KEY: ${{ secrets.CAPSULE_SIGN_KEY }} + run: | # ci/pki/make-ca.sh; kernel/Makefile signs with SIGN_KEY + if [ -n "$KEY" ]; then + f="${RUNNER_TEMP:-/tmp}/capsule-signing.key" + (umask 077; printf '%s\n' "$KEY" > "$f") + echo "SIGN_KEY=$f" >> "$GITHUB_ENV"; echo "The capsules will be signed." + else + echo "::warning::No CAPSULE_SIGN_KEY secret: the capsules are built unsigned." + fi + - name: make -C v4 test run: make -C v4 test diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index 7a3d89d3..7f04f6f7 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -39,6 +39,18 @@ jobs: fetch-depth: 0 # the SBOM's version is git describe lfs: false # only the logs and DoE CSVs are in LFS; the build needs neither + - name: The capsule-signing key + env: + KEY: ${{ secrets.CAPSULE_SIGN_KEY }} + run: | # ci/pki/make-ca.sh; kernel/Makefile signs with SIGN_KEY + if [ -n "$KEY" ]; then + f="${RUNNER_TEMP:-/tmp}/capsule-signing.key" + (umask 077; printf '%s\n' "$KEY" > "$f") + echo "SIGN_KEY=$f" >> "$GITHUB_ENV"; echo "The capsules will be signed." + else + echo "::warning::No CAPSULE_SIGN_KEY secret: the capsules are built unsigned." + fi + - name: Acceptance amd64 run: scripts/nightly.sh accept amd64 - name: Acceptance aarch64 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 141ae3ae..e248f06b 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -33,6 +33,18 @@ jobs: fetch-depth: 0 lfs: false + - name: The capsule-signing key + env: + KEY: ${{ secrets.CAPSULE_SIGN_KEY }} + run: | # ci/pki/make-ca.sh; kernel/Makefile signs with SIGN_KEY + if [ -n "$KEY" ]; then + f="${RUNNER_TEMP:-/tmp}/capsule-signing.key" + (umask 077; printf '%s\n' "$KEY" > "$f") + echo "SIGN_KEY=$f" >> "$GITHUB_ENV"; echo "The capsules will be signed." + else + echo "::warning::No CAPSULE_SIGN_KEY secret: the capsules are built unsigned." + fi + - name: The tag names the tree's version run: | tree="v$(sed -n 's/^LITHOS_VERSION *?= *//p' kernel/Makefile)" diff --git a/ci/pki/make-ca.sh b/ci/pki/make-ca.sh new file mode 100755 index 00000000..a1331597 --- /dev/null +++ b/ci/pki/make-ca.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# make-ca.sh -- the StarshipOS capsule-signing PKI, made once, on the CI box (Captain Bob, +# 2026-11-07: "New root CA on the CI box. Valid StarshipOS rather than snake oil intermediate"). +# +# StarshipOS Root CA Ed25519, 20 years, self-signed. Its key never leaves this box: +# not in the repository, not in Gitea, not in any image. +# StarshipOS Capsule Signing Ed25519, 10 years, signed by the root; CA:TRUE, pathlen:0. +# Its certificate is built into every image (capsules/pki/ +# starshipos-intermediate.der) and checks each capsule at boot; +# its key signs the capsules (mkcapsule --sign-key), in CI from +# the repository secret CAPSULE_SIGN_KEY. +# +# Replaces the offline LithosAnanke Root CA and its snakeoil intermediate (FABRIC-2.md, +# Milestone 6) for every build. Run as root: +# +# sudo ci/pki/make-ca.sh [dir] # dir defaults to /var/lib/starshipos-ca +# +# It refuses to overwrite an existing CA. When it is done it prints the two certificates (public: +# paste them where they are wanted) and says how to put the signing key into Gitea. With +# GITEA_TOKEN set (a token that may administer the repository), it stores the secret itself. +set -euo pipefail +umask 077 +dir=${1:-/var/lib/starshipos-ca} +repo=${GITEA_REPO:-rajames/LithosAnanke} +api=${GITEA_API:-https://gitea.starshipos-f18.org/api/v1} + +command -v openssl >/dev/null || { echo "make-ca: needs openssl" >&2; exit 1; } +[ -e "$dir/root/root.key" ] && { echo "make-ca: $dir already holds a CA; not overwriting it" >&2; exit 1; } +mkdir -p "$dir/root" "$dir/signing" +cd "$dir" + +cat > ca.cnf <<'CNF' +[ req ] +distinguished_name = dn +prompt = no +[ dn ] +[ root_ext ] +basicConstraints = critical, CA:TRUE +keyUsage = critical, keyCertSign, cRLSign +subjectKeyIdentifier = hash +[ signing_ext ] +basicConstraints = critical, CA:TRUE, pathlen:0 +keyUsage = critical, keyCertSign, cRLSign, digitalSignature +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always +CNF + +# the root +openssl genpkey -algorithm ed25519 -out root/root.key +openssl req -new -x509 -key root/root.key -days 7305 -config ca.cnf -extensions root_ext \ + -subj "/O=StarshipOS/CN=StarshipOS Root CA" -out root/root.pem + +# the capsule-signing intermediate, signed by the root +openssl genpkey -algorithm ed25519 -out signing/signing.key +openssl req -new -key signing/signing.key -config ca.cnf \ + -subj "/O=StarshipOS/CN=StarshipOS Capsule Signing" -out signing/signing.csr +openssl x509 -req -in signing/signing.csr -CA root/root.pem -CAkey root/root.key \ + -CAcreateserial -days 3652 -extfile ca.cnf -extensions signing_ext -out signing/signing.pem +openssl x509 -in signing/signing.pem -outform DER -out signing/starshipos-intermediate.der +openssl verify -CAfile root/root.pem signing/signing.pem + +chmod 700 root signing; chmod 600 root/* signing/* + +if [ -n "${GITEA_TOKEN:-}" ]; then + python3 -c 'import json,sys; print(json.dumps({"data": open(sys.argv[1]).read()}))' signing/signing.key | + curl -fsS -o /dev/null -X PUT -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \ + "$api/repos/$repo/actions/secrets/CAPSULE_SIGN_KEY" -d @- && + echo "make-ca: stored the signing key as $repo's secret CAPSULE_SIGN_KEY" +else + echo "make-ca: put $dir/signing/signing.key, whole, into $repo's Actions secret CAPSULE_SIGN_KEY" + echo " (Settings -> Actions -> Secrets), or run again with GITEA_TOKEN set -- after" + echo " removing $dir, since it refuses to overwrite a CA." +fi +echo +echo "=== The root certificate (public) -- $dir/root/root.pem" +cat root/root.pem +echo "=== The capsule-signing certificate (public) -- $dir/signing/signing.pem" +cat signing/signing.pem +echo "=== Fingerprints" +openssl x509 -in root/root.pem -noout -fingerprint -sha256 -subject -enddate +openssl x509 -in signing/signing.pem -noout -fingerprint -sha256 -subject -issuer -enddate diff --git a/docs/fabric/FABRIC-3.md b/docs/fabric/FABRIC-3.md index d41d95c9..16144bca 100644 --- a/docs/fabric/FABRIC-3.md +++ b/docs/fabric/FABRIC-3.md @@ -6061,3 +6061,47 @@ four conflicts resolved to the branch's files (`92ca805e`). - The first scheduled nightly's publish to Nexus (#17). - The Pi 5 and Mars images booted on their boards (#27, #28); a Pi 4 port (#26). - Revoke the access token `claude-session-20261106` (Settings, Applications). + +## XLVI. The StarshipOS CA: capsules signed in CI, by a root on the CI box (2026-11-07) + +### 1. Found + +Every image built anywhere but Captain Bob's machine carried unsigned capsules: CI, the +acceptance runs, the nightly bound for Nexus, a release, the demo. The capsule-signing key -- the +snakeoil intermediate of the offline LithosAnanke Root CA (FABRIC-2.md, Milestone 6) -- lived only +there, `kernel/Makefile`'s `SIGN_KEY` named its path, and a missing signature is a warning by +design (2026-08-26), so they booted: `V4: capsule v4:sys:core.f18 signature: missing (unsigned)` +in every accepted log (`logs/20261106-004933`). And `mkcapsule` signed with whatever key it was +given: a key that is not the embedded certificate's makes every signature INVALID, which refuses +Mama. + +### 2. Ruled + +Captain Bob, 2026-11-07: "New root CA on the CI box. Valid StarshipOS rather than snake oil +intermediate." + +### 3. Built + +- `ci/pki/make-ca.sh`, run once on the CI box: **StarshipOS Root CA** (Ed25519, 20 years, + self-signed; its key stays on that box, in `/var/lib/starshipos-ca`) and **StarshipOS Capsule + Signing** (Ed25519, 10 years, issued by the root, `CA:TRUE, pathlen:0`). It refuses to + overwrite a CA, prints both certificates, and stores the signing key as the repository secret + `CAPSULE_SIGN_KEY` (or says how). +- The image carries `capsules/pki/starshipos-intermediate.der` in place of the snakeoil + intermediate; the kernel finds it by one name, `CAPSULE_SIGNING_CERT_NAME` (`capsule_sig.h`). +- `mkcapsule` checks the signing key against that certificate before it writes anything, and + refuses a key that is not its, or a key with no certificate to check. +- `SIGN_KEY` has no default (`kernel/Makefile`, `v4/Makefile`): CI sets it from the secret (a step + in `acceptance.yml`, `build.yml`, `nightly.yml`, `release.yml`); unset, the capsules are built + unsigned, as before. + +What it is not: the kernel still does not check the intermediate against a root at boot +(`x509_ed25519.h`; `ACL.4th`'s `ACL-CA-KEY` is still 0). A signature proves a capsule matches the +certificate in the same image. That is unchanged by this, and a ruling of its own. + +### 4. Verified + +With a CA made by `make-ca.sh` in a scratch directory (not the real one): `openssl verify` of the +chain OK; `make-ca.sh` refuses a second run; `mkcapsule` with the signing key -- "signing key +matches"; with the root's key -- refused; with a key and no certificate -- refused; with no key -- +unsigned, as before. The acceptance with the real certificate is the PR's. diff --git a/kernel/Makefile b/kernel/Makefile index ddbead77..31f425f8 100644 --- a/kernel/Makefile +++ b/kernel/Makefile @@ -207,7 +207,8 @@ MKCAPSULE_SRC = tools/mkcapsule.c tools/pkcs8_ed25519.c \ kernel/src/crypto/ed25519.c \ kernel/src/crypto/fe25519.c \ kernel/src/crypto/scalar25519.c \ - kernel/src/crypto/sha512.c + kernel/src/crypto/sha512.c \ + kernel/src/crypto/x509_ed25519.c MKCAPSULE_BIN = $(BUILD_DIR)/tools/mkcapsule # mkartemis: formats and signs an Artemis partition at build with the kernel's own # block-subsystem and artemis_sig code (tools/mkartemis.c), built as v4/Makefile builds them. @@ -215,12 +216,11 @@ MKARTEMIS_SRC = tools/mkartemis.c kernel/src/artemis_sig.c \ $(addprefix v3/src/,block_subsystem.c blkio_ram.c blkio_file.c log.c \ platform/platform_init.c platform/linux/time.c) MKARTEMIS_BIN = $(BUILD_DIR)/tools/mkartemis -# Milestone 6 (Phase 8): the snakeoil intermediate's private key, generated -# offline outside this repo entirely (see FABRIC-2.md's Phase 8 §Milestone 6 -# writeup) -- not present in CI or a fresh checkout, so signing is skipped -# gracefully ($(wildcard ...) below) rather than failing the build. Override -# with `make SIGN_KEY=/path/to/key.pem ...` on a machine that holds it. -SIGN_KEY ?= /home/rajames/CLionProjects/lithosananke-ca/intermediate/snakeoil-intermediate.key +# The capsule-signing key: "StarshipOS Capsule Signing"'s private key (ci/pki/make-ca.sh, +# FABRIC-3.md XLVI). CI sets SIGN_KEY from the secret CAPSULE_SIGN_KEY; elsewhere set it by hand +# (`make SIGN_KEY=/path/to/signing.key ...`). Unset or absent, the capsules are built unsigned, +# which boots with a warning per capsule. mkcapsule refuses a key that is not the certificate's. +SIGN_KEY ?= SIGN_KEY_ARGS = $(if $(wildcard $(SIGN_KEY)),--sign-key $(SIGN_KEY),) CAPSULE_GENERATED = $(BUILD_DIR)/capsule_generated.c CAPSULE_GENERATED_OBJ = $(BUILD_DIR)/capsule_generated.o diff --git a/kernel/include/starkernel/capsule.h b/kernel/include/starkernel/capsule.h index 883c85e5..1122ade0 100644 --- a/kernel/include/starkernel/capsule.h +++ b/kernel/include/starkernel/capsule.h @@ -171,7 +171,8 @@ typedef struct { * * Milestone 6 (Phase 8): each capsule's payload bytes (the same bytes * content_hash already covers), signed by mkcapsule at build time with - * the snakeoil intermediate's private key. has_sig=0 for a capsule built + * the capsule-signing key (StarshipOS Capsule Signing, capsule_sig.h; + * the snakeoil intermediate's until 2026-11-07). has_sig=0 for a capsule built * before this feature existed or otherwise unsigned -- a real, distinct * state, not "signature is all-zero bytes" (which sig[64] full of 0x00 * would otherwise look ambiguous with). Indexed 1:1 with diff --git a/kernel/include/starkernel/capsule_sig.h b/kernel/include/starkernel/capsule_sig.h index 32ae68b1..b2bb3fff 100644 --- a/kernel/include/starkernel/capsule_sig.h +++ b/kernel/include/starkernel/capsule_sig.h @@ -22,6 +22,12 @@ #include "starkernel/capsule.h" +/* The certificate whose key the capsules are signed with: "StarshipOS + * Capsule Signing", issued by the StarshipOS Root CA (ci/pki/make-ca.sh; + * FABRIC-3.md XLVI), replacing the snakeoil intermediate of Milestone 6. + * mkcapsule refuses to sign with a key that is not this certificate's. */ +#define CAPSULE_SIGNING_CERT_NAME "pki:starshipos-intermediate.der" + typedef enum { CAPSULE_SIG_OK = 0, /* has_sig=1, and it verifies */ CAPSULE_SIG_MISSING, /* has_sig=0 -- not signed at all */ @@ -31,9 +37,9 @@ typedef enum { /* * Verify capsule descs[index]'s Ed25519 signature against the embedded - * snakeoil intermediate cert's public key (capsule name - * "pki:snakeoil-intermediate.der", found and parsed once, cached for - * every later call this boot -- the cert doesn't change mid-boot). + * capsule-signing certificate's public key (CAPSULE_SIGNING_CERT_NAME, + * found and parsed once, cached for every later call this boot -- the + * cert doesn't change mid-boot). * * descs/names/sigs must be the same three parallel arrays * (capsule_get_descriptors()/capsule_get_names()/capsule_get_signatures()), diff --git a/kernel/include/starkernel/x509_ed25519.h b/kernel/include/starkernel/x509_ed25519.h index 1aa96b5f..a2f0521d 100644 --- a/kernel/include/starkernel/x509_ed25519.h +++ b/kernel/include/starkernel/x509_ed25519.h @@ -6,9 +6,10 @@ * * x509_extract_ed25519_pubkey() only ever reads SubjectPublicKeyInfo -- * no signature verification, no chain validation, no extension parsing. - * That's the capsule-PKI use case (Milestone 6): the embedded snakeoil - * intermediate cert is trusted because it's baked into the trusted build, - * never re-verified against the offline root CA at boot. + * That's the capsule-PKI use case (Milestone 6): the embedded capsule- + * signing cert (StarshipOS Capsule Signing since 2026-11-07, the snakeoil + * intermediate before it) is trusted because it's baked into the trusted + * build, never re-verified against its root CA at boot. * * x509_verify_signature()/x509_extract_serial() (added 2026-08-28, * FABRIC-2.md §F.7/§F.17) are for CERTVERIFY -- a regular user's cert, diff --git a/kernel/src/capsule/capsule_sig.c b/kernel/src/capsule/capsule_sig.c index 6d46f7f1..759decb5 100644 --- a/kernel/src/capsule/capsule_sig.c +++ b/kernel/src/capsule/capsule_sig.c @@ -4,8 +4,6 @@ #include "starkernel/ed25519.h" #include -#define SNAKEOIL_CERT_NAME "pki:snakeoil-intermediate.der" - /* Cached once per boot: the cert doesn't change mid-boot, and every * capsule birth (Mama, then every child VM) would otherwise re-find and * re-parse the same cert. -1 = not yet attempted, 0 = failed, 1 = ready. */ @@ -20,7 +18,7 @@ static int find_and_cache_pubkey(const CapsuleDesc *descs, pubkey_state = 0; /* assume failure unless every step below succeeds */ for (uint32_t i = 0; i < desc_count; i++) { - if (strcmp(names[i].name, SNAKEOIL_CERT_NAME) == 0) { + if (strcmp(names[i].name, CAPSULE_SIGNING_CERT_NAME) == 0) { const CapsuleDesc *cert_desc = &descs[i]; if (x509_extract_ed25519_pubkey(arena_base + cert_desc->offset, (size_t)cert_desc->length, diff --git a/tools/mkcapsule.c b/tools/mkcapsule.c index d8b419b5..c9237f74 100644 --- a/tools/mkcapsule.c +++ b/tools/mkcapsule.c @@ -56,6 +56,8 @@ #include "pkcs8_ed25519.h" #include "starkernel/ed25519.h" +#include "starkernel/x509_ed25519.h" +#include "starkernel/capsule_sig.h" /*=========================================================================== * xxHash64 (embedded for build tool — no external dependency) @@ -1575,6 +1577,34 @@ static int load_sign_key(const char *key_path) { return 0; } +/* The signing key must be the one whose certificate the image carries + * (CAPSULE_SIGNING_CERT_NAME): the kernel checks every signature against + * that certificate, and refuses a signature that does not verify -- Mama's + * own included. A key that is not the certificate's would build an image + * that cannot boot, so refuse to build it. Returns 0 if they match. */ +static int check_sign_key_matches_cert(void) { + uint8_t key_pub[32], cert_pub[32]; + ed25519_keygen(sign_seed, key_pub); + for (int i = 0; i < capsule_count; i++) { + if (strcmp(capsules[i].name, CAPSULE_SIGNING_CERT_NAME) != 0) continue; + if (x509_extract_ed25519_pubkey(capsules[i].data, capsules[i].length, cert_pub) != 0) { + fprintf(stderr, "mkcapsule: ERROR: %s is not an Ed25519 certificate\n", + CAPSULE_SIGNING_CERT_NAME); + return -1; + } + if (memcmp(key_pub, cert_pub, 32) != 0) { + fprintf(stderr, "mkcapsule: ERROR: the signing key is not %s's -- every " + "capsule would fail its check at boot\n", CAPSULE_SIGNING_CERT_NAME); + return -1; + } + fprintf(stderr, "mkcapsule: signing key matches %s\n", CAPSULE_SIGNING_CERT_NAME); + return 0; + } + fprintf(stderr, "mkcapsule: ERROR: --sign-key given, but no %s to check it against\n", + CAPSULE_SIGNING_CERT_NAME); + return -1; +} + int main(int argc, char **argv) { /* --manifest mode: generate block ownership markdown. Optional * --sign-key prefix: mkcapsule --manifest [--sign-key @@ -1717,6 +1747,10 @@ int main(int argc, char **argv) { fprintf(stderr, "mkcapsule: %d capsule(s) collected\n", capsule_count); + if (have_sign_seed && check_sign_key_matches_cert() != 0) { + return 1; + } + FILE *out = fopen(output_path, "w"); if (!out) { fprintf(stderr, "mkcapsule: ERROR: cannot create '%s'\n", output_path); diff --git a/v4/Makefile b/v4/Makefile index 35b40021..bfab3332 100644 --- a/v4/Makefile +++ b/v4/Makefile @@ -173,10 +173,12 @@ RUN_amd64 ?= RUN_aarch64 ?= qemu-aarch64 RUN_riscv64 ?= qemu-riscv64 -SIGN_KEY ?= /home/rajames/CLionProjects/lithosananke-ca/intermediate/snakeoil-intermediate.key +# as kernel/Makefile: CI sets SIGN_KEY; unset, the capsules are built unsigned +SIGN_KEY ?= SIGN_KEY_ARGS = $(if $(wildcard $(SIGN_KEY)),--sign-key $(SIGN_KEY),) CRYPTO_SRCS := $(addprefix $(ROOT)/kernel/src/crypto/,ed25519.c fe25519.c scalar25519.c sha512.c) -MKCAPSULE_SRCS := $(ROOT)/tools/mkcapsule.c $(ROOT)/tools/pkcs8_ed25519.c $(CRYPTO_SRCS) +MKCAPSULE_SRCS := $(ROOT)/tools/mkcapsule.c $(ROOT)/tools/pkcs8_ed25519.c $(CRYPTO_SRCS) \ + $(ROOT)/kernel/src/crypto/x509_ed25519.c CAPSULE_FILES := $(shell find $(ROOT)/capsules -type f ! -name '.*' 2>/dev/null) CAPSULE_DIR_C := $(BINDIR)/capsule_generated.c MACHINE_64 := $(BINDIR)/rom-64.c $(BINDIR)/v4_map_64.c