pki: the StarshipOS CA, made on the CI box; capsules signed in CI
Build / Image RASPI (push) Successful in 2m17s
Build / Image PC (push) Successful in 2m31s
Build / Image MILKV (push) Successful in 2m20s
Build / v4 tests (32- and 64-bit cells) (push) Successful in 8m51s

Ruled by Captain Bob, 2026-11-07: "New root CA on the CI box. Valid
StarshipOS rather than snake oil intermediate."

- ci/pki/make-ca.sh makes the StarshipOS Root CA (its key stays on the CI
  box) and the StarshipOS Capsule Signing intermediate it issues, and
  stores the signing key as the secret CAPSULE_SIGN_KEY.
- The kernel finds the signing certificate by one name,
  CAPSULE_SIGNING_CERT_NAME = pki:starshipos-intermediate.der. The
  certificate itself lands once make-ca.sh has run; until then the
  snakeoil intermediate is in the tree under its old name and the
  capsules check as "no root key" (a warning, as "missing" was).
- mkcapsule refuses a signing key that is not that certificate's.
- SIGN_KEY has no default; acceptance, build, nightly and release set it
  from the secret.

FABRIC-3.md XLVI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GE3SDvQn5EoxdGufk84zz2
This commit is contained in:
Claude
2026-11-07 04:15:29 +00:00
parent 4ae1f759d5
commit 03027f9769
13 changed files with 246 additions and 19 deletions
+12
View File
@@ -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
+24
View File
@@ -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
+12
View File
@@ -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
+12
View File
@@ -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)"
+81
View File
@@ -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
+44
View File
@@ -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.
+7 -7
View File
@@ -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
+2 -1
View File
@@ -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
+9 -3
View File
@@ -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()),
+4 -3
View File
@@ -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,
+1 -3
View File
@@ -4,8 +4,6 @@
#include "starkernel/ed25519.h"
#include <string.h>
#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,
+34
View File
@@ -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 <keyfile> 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);
+4 -2
View File
@@ -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