Milestone 6: mkcapsule signing + capsule_birth.c wiring, WARN-only

First attempt shelled out to `openssl pkeyutl -sign` (fork/execlp, not
system() -- avoided shell string interpolation of the key path).
Corrected on request: no new external host binary dependency when the
repo's own code can do the job -- same standing preference as the
earlier anti-file correction. Rewritten to link ed25519_sign() (already
verified against OpenSSL in Phase B) directly into mkcapsule.

New tools/pkcs8_ed25519.c: a narrow DER walker (same shape as
x509_ed25519.c, deliberately not shared -- small enough that
duplicating a few TLV-walking lines beat threading a header between the
kernel crypto tree and host tooling) extracting the raw seed from the
intermediate's PKCS#8 private key, plus a minimal self-written base64
decoder (PEM is openssl genpkey's default output; no decoder existed
anywhere in the repo). Verified end-to-end before wiring anything in:
the extracted seed's derived pubkey matches the cert's exactly, and a
full self-contained sign+verify round-trip (zero openssl) passes.

CapsuleDesc had no spare bytes, so signatures live in a new parallel
CapsuleSigEntry array, emitted by a new `mkcapsule --sign-key <path>`
flag (omitted/missing key -> has_sig=0 everywhere, graceful, not a
build failure -- CI has no access to the offline key).

New capsule_sig.c/.h: capsule_verify_signature(), a separate function,
not folded into the already-tested capsule_validate(). Finds and caches
the embedded intermediate cert's pubkey once per boot, then verifies
against it. Wired into all three capsule_validate() call sites in
capsule_birth.c via log_message(LOG_WARN, ...) -- never refuses yet,
per the earlier staged-rollout decision.

Verified independently, both directions, live in the real kernel: a
full clean build (38 signed capsules) boots clean on all three
architectures with zero warnings. Separately, hand-corrupted one byte
of Mama's own init.4th capsule's stored signature (not its payload/hash,
which capsule_validate() already catches and would have masked the
test) and rebuilt just the changed object: produced exactly "capsule
sig: init.4th: INVALID -- signature does not verify" on boot, and the
kernel still reached ok> -- proving warn-only doesn't refuse anything
yet. Reverted before the final, untampered 3-arch acceptance pass.

Still open: flipping WARN to hard-refuse (separate, deliberate step)
and the BLOCK_MAP.md signature-status column. Documented in FABRIC-3.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U14ET9CWAtbQMbYqomKgXd
This commit is contained in:
Robert Allan James
2026-08-26 21:32:29 -04:00
co-authored by Claude Sonnet 5
parent 431bcb1f34
commit 2fc55f47e1
16 changed files with 45762 additions and 11 deletions
+58
View File
@@ -744,6 +744,64 @@ decisions get added here, not to `FABRIC-2.md`. Follow the same discipline `FABR
— `capsules/MANIFEST_AUTO.md` is stale/dead, not regenerated since 2026-07-05, flag as docs
drift rather than a real target).
**mkcapsule signing + capsule_birth.c wiring, done 2026-08-26 -- landed WARN-only exactly as
decided.** First attempt shelled out to `openssl pkeyutl -sign` (`fork`/`execlp`, not
`system()` -- interpolating the key path into a shell string would have been a real, if
narrow, injection vector). **Corrected on request: no new external host binary dependency
when the repo's own code can do the job** — this project's standing preference, same theme
as the earlier anti-file correction. Rewritten to link `ed25519_sign()`
(`src/starkernel/crypto/ed25519.c`, already independently verified against OpenSSL in Phase
B) directly into `mkcapsule`. This needed one more small piece: `tools/pkcs8_ed25519.c`, a
narrow DER walker (same shape as `x509_ed25519.c`, deliberately not shared code — small
enough that duplicating a few TLV-walking lines was simpler than threading a header between
the kernel crypto tree and host tooling) extracting the raw 32-byte seed from the
intermediate's PKCS#8 private key, plus a minimal self-written base64 decoder (PEM is
`openssl genpkey`'s default output, not raw DER — checked the repo for an existing decoder
first, found none). **Verified end-to-end before wiring anything in:** the extracted seed,
run through this project's own `ed25519_keygen()`, reproduces the exact pubkey embedded in
the cert; a full self-contained sign+verify round-trip (zero openssl involvement) passes.
`CapsuleDesc` had no spare bytes (confirmed exactly 64, every field used), so signatures live
in a new parallel array, `CapsuleSigEntry capsule_signatures[]` (`{ sig[64], has_sig }`,
mirroring `capsule_names[]`'s existing shape), emitted by `mkcapsule --sign-key <path>` (new
optional CLI flag; omitted or the key file missing → every capsule gets `has_sig=0`,
graceful, not a build failure -- CI has no access to the offline key). New
`Makefile.starkernel` variable `SIGN_KEY` (defaults to this machine's actual key path,
`?=`-overridable, gated by `$(wildcard ...)` so a fresh checkout or CI never fails for
lacking it).
New `capsule_sig.c`/`.h`: `capsule_verify_signature()`, deliberately a separate, additive
function, not folded into the already-tested `capsule_validate()` (whose signature and
behavior stay untouched). Finds and parses the embedded `pki:snakeoil-intermediate.der`
capsule's pubkey once per boot (cached — every later capsule birth reuses it, the cert can't
change mid-boot), then `ed25519_verify()`s the requested capsule's payload against its stored
signature. Wired into all three `capsule_validate()` call sites in `capsule_birth.c`
(`capsule_birth_mama`, `capsule_birth_baby`, `capsule_run_experiment`) via
`log_message(LOG_WARN, ...)` on anything other than `CAPSULE_SIG_OK` — never refuses.
(Hit one unrelated build snag wiring this in: `vm.h` and `log.h` both define `LOG_LINE_MAX`
for two completely unrelated concepts — vm.h's own is the persistent block-log line size,
unconditional `#define`; log.h's is `#ifndef`-guarded. `log.h` has to be included *after*
`vm.h` in any TU that needs both, or the build fails under `-Werror`; fixed and commented in
`capsule_birth.c` so the next person touching includes there doesn't rediscover it the hard
way.)
**Verified independently, both directions, live in the real kernel:** a full clean build (38
real capsules, all signed) boots clean on all three architectures with zero signature
warnings — the expected outcome when every signature is genuinely valid, not merely an
absence of testing. Separately, hand-corrupted one byte of Mama's own `init.4th` capsule's
*stored signature* (not its payload/hash, which `capsule_validate()` already checks
independently and would have masked the test) directly in the generated
`capsule_generated.c`, without regenerating from source, and rebuilt only the changed object:
produced exactly `capsule sig: init.4th: INVALID -- signature does not verify` on boot, and
**the kernel still reached `ok>`** — proving warn-only genuinely doesn't refuse anything yet,
precisely as decided. Reverted the tampered build before the final acceptance pass. Final,
untampered state: clean 3-arch acceptance boot, conservation intact, zero warnings, real disk
untouched.
**Still open:** flipping WARN to hard-refuse (a separate, deliberate step once this has run
clean for a while, not bundled into this pass) and the `BLOCK_MAP.md` signature-status
column.
### From FABRIC-2.md §X, Milestone 7 — Contributor capsules / trust tiers
- [ ] Create the `capsules/contrib/` directory (mechanically trivial, matches existing
+15 -4
View File
@@ -101,8 +101,19 @@ DOE_LATEST_DIR := experiments/bare_metal/latest
CAPSULES_DIR ?= capsules
ARTDISK ?= disk/artemis.img
MKCAPSULE_SRC = tools/mkcapsule.c
MKCAPSULE_SRC = tools/mkcapsule.c tools/pkcs8_ed25519.c \
src/starkernel/crypto/ed25519.c \
src/starkernel/crypto/fe25519.c \
src/starkernel/crypto/scalar25519.c \
src/starkernel/crypto/sha512.c
MKCAPSULE_BIN = $(BUILD_DIR)/tools/mkcapsule
# Milestone 6 (Phase 8): the snakeoil intermediate's private key, generated
# offline outside this repo entirely (see FABRIC-3.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
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
CAPSULE_GENERATED_KOBJ = $(KERNEL_OBJ_DIR)/capsule_generated.o
@@ -567,15 +578,15 @@ include/version.h:
# Host tool: capsule packer
$(MKCAPSULE_BIN): $(MKCAPSULE_SRC)
@mkdir -p $(dir $@)
@echo "HOSTCC $<"
@cc -std=c99 -Wall -Wextra -O2 -o $@ $<
@echo "HOSTCC $(MKCAPSULE_SRC)"
@cc -std=c99 -Wall -Wextra -O2 -Iinclude -Itools -o $@ $(MKCAPSULE_SRC)
# Generate capsule_generated.c from capsules/
CAPSULE_SRCS := $(shell find $(CAPSULES_DIR) -type f ! -name '.*' 2>/dev/null)
$(CAPSULE_GENERATED): $(MKCAPSULE_BIN) $(CAPSULE_SRCS)
@mkdir -p $(dir $@)
@echo " MKCAP $(CAPSULES_DIR) -> $@"
@$(MKCAPSULE_BIN) $(CAPSULES_DIR) $@
@$(MKCAPSULE_BIN) $(SIGN_KEY_ARGS) $(CAPSULES_DIR) $@
@echo " MKCAP $(CAPSULES_DIR) -> $(CAPSULES_DIR)/BLOCK_MAP.md"
@$(MKCAPSULE_BIN) --manifest $(CAPSULES_DIR) $(CAPSULES_DIR)/BLOCK_MAP.md
+1 -1
View File
@@ -1,5 +1,5 @@
# Capsule Block Manifest — Auto-generated
<!-- Generated by mkcapsule --manifest 2026-08-27T00:54:08Z -->
<!-- Generated by mkcapsule --manifest 2026-08-27T01:28:54Z -->
<!-- DO NOT EDIT — re-run mkcapsule --manifest to refresh. -->
<!-- Hand-written justifications and immutability notes live -->
<!-- in MANIFEST.md alongside this auto-generated index. -->
+18
View File
@@ -156,6 +156,24 @@ typedef struct {
char name[CAPSULE_NAME_MAX]; /* null-terminated, colon-separated path */
} CapsuleNameEntry;
/*===========================================================================
* CapsuleSigEntry - Ed25519 signature (parallel array to CapsuleDesc[])
*
* 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
* 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
* capsule_descriptors[], same convention as CapsuleNameEntry.
*===========================================================================*/
typedef struct {
uint8_t sig[64]; /* raw Ed25519 R||S, see ed25519_sign()/ed25519_verify() */
uint8_t has_sig; /* 0 = no signature present, 1 = sig[] is real */
uint8_t _pad[7];
} CapsuleSigEntry;
/*===========================================================================
* CapsuleDirHeader - Directory Header
*===========================================================================*/
+4
View File
@@ -31,6 +31,8 @@ extern const CapsuleDesc capsule_descriptors[];
__attribute__((visibility("hidden")))
extern const CapsuleNameEntry capsule_names[];
__attribute__((visibility("hidden")))
extern const CapsuleSigEntry capsule_signatures[];
__attribute__((visibility("hidden")))
extern const CapsuleDirHeader capsule_directory;
/*
@@ -50,6 +52,8 @@ const CapsuleDesc *capsule_get_descriptors(void);
__attribute__((visibility("hidden")))
const CapsuleNameEntry *capsule_get_names(void);
__attribute__((visibility("hidden")))
const CapsuleSigEntry *capsule_get_signatures(void);
__attribute__((visibility("hidden")))
const uint8_t *capsule_get_arena(void);
#ifdef __cplusplus
+48
View File
@@ -0,0 +1,48 @@
/*
* capsule_sig.h -- per-capsule Ed25519 signature verification
* (Milestone 6, Phase 8). Deliberately kept separate from
* capsule_validate.c: that function is already tested and its
* signature/behavior stays untouched; this is a new, additive check
* called alongside it, not folded into it.
*
* Currently WARN-only, not enforced -- see FABRIC-3.md's Milestone 6
* rollout decision. A bug here has a larger blast radius than most of
* this project's other checks: a false refusal on Mama's own capsule
* would mean no `ok>` at all, on any architecture. Land warn-only,
* prove correct against both a valid and a deliberately-corrupted
* capsule on all three architectures, then flip to hard-refuse
* separately.
*/
#ifndef STARKERNEL_CAPSULE_SIG_H
#define STARKERNEL_CAPSULE_SIG_H
#include "starkernel/capsule.h"
typedef enum {
CAPSULE_SIG_OK = 0, /* has_sig=1, and it verifies */
CAPSULE_SIG_MISSING, /* has_sig=0 -- not signed at all */
CAPSULE_SIG_INVALID, /* has_sig=1 but verification failed */
CAPSULE_SIG_NO_ROOT_KEY, /* couldn't find/parse the embedded intermediate cert */
} CapsuleSigResult;
/*
* 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).
*
* descs/names/sigs must be the same three parallel arrays
* (capsule_get_descriptors()/capsule_get_names()/capsule_get_signatures()),
* desc_count their shared length, arena_base the payload arena
* (capsule_get_arena()). index must be < desc_count.
*/
CapsuleSigResult capsule_verify_signature(
const CapsuleDesc *descs, const CapsuleNameEntry *names,
const CapsuleSigEntry *sigs, const uint8_t *arena_base,
uint32_t desc_count, int index);
/* Human-readable string for logging, mirroring
* capsule_validate_result_str()'s existing shape. */
const char *capsule_sig_result_str(CapsuleSigResult result);
#endif /* STARKERNEL_CAPSULE_SIG_H */
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+42
View File
@@ -29,11 +29,18 @@
#include "starkernel/capsule_birth.h"
#include "starkernel/capsule.h"
#include "starkernel/capsule_run.h"
#include "starkernel/capsule_generated.h" /* capsule_get_signatures() */
#include "starkernel/capsule_sig.h"
#include "starkernel/kmalloc.h"
#include "starkernel/console.h"
#include "starkernel/vm/stadium.h" /* item 4.1a -- stadium_grant_quota() */
#include "vm.h"
#include "platform_alloc.h"
/* log.h after vm.h: vm.h's own LOG_LINE_MAX (persistent block-log,
* unrelated concept, unconditional #define) must win before log.h's
* #ifndef-guarded one sees it -- reversed order redefines and fails
* -Werror (found 2026-08-26 wiring in capsule signature logging). */
#include "log.h"
/*===========================================================================
* VM Execution Hooks
@@ -409,6 +416,19 @@ CapsuleRunResult capsule_birth_mama(
CapsuleValidateResult vr = capsule_validate(mama_cap, arena, dir->arena_size, 1);
if (vr != CAPSULE_VALID) return CAPSULE_RUN_ERR_INVALID;
/* Milestone 6 (Phase 8): WARN-only signature check, not yet enforced --
* see capsule_sig.h's own doc comment for why (blast radius: a false
* refusal here means Mama herself never births, no ok> on any arch). */
{
int idx = (int)(mama_cap - descs);
CapsuleSigResult sr = capsule_verify_signature(
descs, names, capsule_get_signatures(), arena, dir->desc_count, idx);
if (sr != CAPSULE_SIG_OK) {
log_message(LOG_WARN, "capsule sig: %s: %s",
names[idx].name, capsule_sig_result_str(sr));
}
}
uint64_t pre_dict_hash = vm_dict_hash_fn(mama_vm);
(void)pre_dict_hash;
@@ -468,6 +488,17 @@ CapsuleRunResult capsule_birth_baby(
CapsuleValidateResult vr = capsule_validate(cap, arena, dir->arena_size, 1);
if (vr != CAPSULE_VALID) return CAPSULE_RUN_ERR_INVALID;
/* Milestone 6 (Phase 8): WARN-only, see capsule_sig.h. */
{
int idx = (int)(cap - descs);
CapsuleSigResult sr = capsule_verify_signature(
descs, names, capsule_get_signatures(), arena, dir->desc_count, idx);
if (sr != CAPSULE_SIG_OK) {
log_message(LOG_WARN, "capsule sig: %s: %s",
names[idx].name, capsule_sig_result_str(sr));
}
}
if (vm_registry_live_count() >= stadium_max_vm_count()) {
capsule_parity_log_birth_failed(vm_uuid_none(), cap->capsule_id,
CAPSULE_RUN_ERR_FLEET_FULL, 0);
@@ -603,6 +634,17 @@ CapsuleRunResult capsule_run_experiment(
CapsuleValidateResult vr = capsule_validate(cap, arena, dir->arena_size, 1);
if (vr != CAPSULE_VALID) return CAPSULE_RUN_ERR_INVALID;
/* Milestone 6 (Phase 8): WARN-only, see capsule_sig.h. */
{
int idx = (int)(cap - descs);
CapsuleSigResult sr = capsule_verify_signature(
descs, names, capsule_get_signatures(), arena, dir->desc_count, idx);
if (sr != CAPSULE_SIG_OK) {
log_message(LOG_WARN, "capsule sig: %s: %s",
names[idx].name, capsule_sig_result_str(sr));
}
}
uint64_t pre_dict_hash = vm_dict_hash_fn(mama_vm);
const uint8_t *payload = capsule_get_payload(cap, arena);
+68
View File
@@ -0,0 +1,68 @@
/* capsule_sig.c -- see capsule_sig.h. */
#include "starkernel/capsule_sig.h"
#include "starkernel/x509_ed25519.h"
#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. */
static int pubkey_state = -1;
static uint8_t cached_pubkey[32];
static int find_and_cache_pubkey(const CapsuleDesc *descs,
const CapsuleNameEntry *names,
const uint8_t *arena_base,
uint32_t desc_count) {
if (pubkey_state != -1) return pubkey_state;
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) {
const CapsuleDesc *cert_desc = &descs[i];
if (x509_extract_ed25519_pubkey(arena_base + cert_desc->offset,
(size_t)cert_desc->length,
cached_pubkey) == 0) {
pubkey_state = 1;
}
break;
}
}
return pubkey_state;
}
CapsuleSigResult capsule_verify_signature(
const CapsuleDesc *descs, const CapsuleNameEntry *names,
const CapsuleSigEntry *sigs, const uint8_t *arena_base,
uint32_t desc_count, int index) {
if (!descs || !names || !sigs || !arena_base || index < 0 ||
(uint32_t)index >= desc_count) {
return CAPSULE_SIG_NO_ROOT_KEY; /* malformed call -- treat like "can't check" */
}
if (!find_and_cache_pubkey(descs, names, arena_base, desc_count)) {
return CAPSULE_SIG_NO_ROOT_KEY;
}
if (!sigs[index].has_sig) {
return CAPSULE_SIG_MISSING;
}
const CapsuleDesc *d = &descs[index];
int ok = ed25519_verify(cached_pubkey, arena_base + d->offset,
(size_t)d->length, sigs[index].sig);
return ok ? CAPSULE_SIG_OK : CAPSULE_SIG_INVALID;
}
const char *capsule_sig_result_str(CapsuleSigResult result) {
switch (result) {
case CAPSULE_SIG_OK: return "ok";
case CAPSULE_SIG_MISSING: return "missing (unsigned)";
case CAPSULE_SIG_INVALID: return "INVALID -- signature does not verify";
case CAPSULE_SIG_NO_ROOT_KEY: return "no root key (cert not found/parseable)";
default: return "unknown";
}
}
+113 -6
View File
@@ -41,6 +41,9 @@
#include <time.h>
#include <sys/stat.h>
#include "pkcs8_ed25519.h"
#include "starkernel/ed25519.h"
/*===========================================================================
* xxHash64 (embedded for build tool — no external dependency)
*===========================================================================*/
@@ -152,6 +155,8 @@ typedef struct {
size_t length; /* File size in bytes */
uint64_t hash; /* xxHash64 of contents */
uint32_t flags; /* Capsule flags */
uint8_t sig[64]; /* Ed25519 R||S, valid iff has_sig */
int has_sig; /* 1 if sig[] was produced by --sign-key */
} CapsuleEntry;
static CapsuleEntry capsules[MAX_CAPSULES];
@@ -169,6 +174,39 @@ typedef struct {
static ManifestEntry manifest_entries[MAX_CAPSULES];
static int manifest_count = 0;
/*===========================================================================
* Capsule signing (Milestone 6, Phase 8) -- host-side only
*===========================================================================
* Signs using this project's own ed25519_sign() (src/starkernel/crypto/
* ed25519.c), linked directly into this tool, and pkcs8_ed25519.c to
* extract the raw seed from the intermediate's PKCS#8 private key file --
* NOT by shelling out to the external `openssl` binary. Corrected
* 2026-08-26: an earlier version of this function forked+exec'd
* `openssl pkeyutl -sign`, reasoning that private-key handling was
* sensitive enough to prefer an already-audited external tool; the
* project's own standing preference is the opposite -- no new host
* binary dependency when the repo's own code (already independently
* verified against OpenSSL in Phase B) can do the job. Verified
* end-to-end (2026-08-26): the seed this extracts, run through this
* project's own ed25519_keygen(), reproduces the exact pubkey embedded
* in the cert, and a full self-contained sign+verify round-trip (no
* openssl involved at all) passes.
*
* The seed is read once at startup (see main()'s --sign-key handling),
* not per-capsule -- this function just signs.
*
* Signs the exact same bytes content_hash already covers (the raw
* capsule payload), not the hash value -- the natural reading of
* "a signature alongside each capsule's existing xxHash64."
*/
static uint8_t sign_seed[32]; /* set once by main() if --sign-key given */
static int have_sign_seed = 0;
static void sign_capsule_bytes(const uint8_t *data, size_t len,
uint8_t sig_out[64]) {
ed25519_sign(sign_seed, data, len, sig_out);
}
/* Flag constants — must match capsule.h */
#define FLAG_ACTIVE 0x00000001
#define FLAG_PRODUCTION 0x00000010
@@ -405,6 +443,12 @@ static int process_file(const char *fpath, const struct stat *sb,
e->length = (size_t)size;
e->hash = xxhash64(data, (size_t)size, 0);
e->flags = flags_from_name(name);
e->has_sig = 0;
if (have_sign_seed) {
sign_capsule_bytes(data, (size_t)size, e->sig);
e->has_sig = 1;
}
capsule_count++;
@@ -412,8 +456,9 @@ static int process_file(const char *fpath, const struct stat *sb,
if (e->flags & FLAG_MAMA_INIT) mode = 'm';
else if (e->flags & FLAG_PRODUCTION) mode = 'p';
fprintf(stderr, " [%c] %s (%" PRIu64 " bytes, hash=0x%016" PRIx64 ")\n",
mode, e->name, (uint64_t)e->length, e->hash);
fprintf(stderr, " [%c] %s (%" PRIu64 " bytes, hash=0x%016" PRIx64 "%s)\n",
mode, e->name, (uint64_t)e->length, e->hash,
e->has_sig ? ", signed" : "");
return 0;
}
@@ -856,6 +901,26 @@ static void generate_output(FILE *out) {
}
fprintf(out, "};\n\n");
/*------------------------------------------------------------------
* Signature array (parallel to descriptors, Milestone 6 / Phase 8)
*------------------------------------------------------------------*/
fprintf(out,
"/*===========================================================================\n"
" * Capsule Signatures (Ed25519 R||S, parallel to descriptors)\n"
" *===========================================================================*/\n\n"
"const CapsuleSigEntry capsule_signatures[%d] = {\n", capsule_count);
for (int i = 0; i < capsule_count; i++) {
CapsuleEntry *e = &capsules[i];
fprintf(out, " /* [%d] %s */\n { .sig = {", i, e->name);
for (int j = 0; j < 64; j++) {
fprintf(out, "0x%02X,", e->sig[j]);
if (j % 16 == 15) fprintf(out, "\n ");
}
fprintf(out, "}, .has_sig = %d },\n", e->has_sig);
}
fprintf(out, "};\n\n");
/*------------------------------------------------------------------
* Directory header
*------------------------------------------------------------------*/
@@ -895,6 +960,8 @@ static void generate_output(FILE *out) {
"__attribute__((visibility(\"hidden\")))\n"
"const CapsuleNameEntry *capsule_get_names(void) { return capsule_names; }\n\n"
"__attribute__((visibility(\"hidden\")))\n"
"const CapsuleSigEntry *capsule_get_signatures(void) { return capsule_signatures; }\n\n"
"__attribute__((visibility(\"hidden\")))\n"
"const uint8_t *capsule_get_arena(void) { return capsule_arena; }\n",
(uint64_t)0x44504143ULL,
capsule_count, MAX_CAPSULES, capsule_count,
@@ -949,10 +1016,50 @@ int main(int argc, char **argv) {
return lint_errors > 0 ? 1 : 0;
}
if (argc != 3) {
/* Optional --sign-key <keyfile> prefix on build mode: mkcapsule
* [--sign-key <keyfile>] <capsules_dir> <output.c>. Read once here,
* not per-capsule -- process_file() just checks have_sign_seed. */
int arg0 = 1;
if (argc >= 2 && strcmp(argv[1], "--sign-key") == 0) {
if (argc != 5) {
fprintf(stderr, "mkcapsule: ERROR: --sign-key requires "
"<keyfile> <capsules_dir> <output.c>\n");
return 1;
}
const char *key_path = argv[2];
FILE *kf = fopen(key_path, "rb");
if (!kf) {
fprintf(stderr, "mkcapsule: ERROR: cannot open key '%s'\n", key_path);
return 1;
}
fseek(kf, 0, SEEK_END);
long klen = ftell(kf);
fseek(kf, 0, SEEK_SET);
uint8_t *kbuf = malloc((size_t)klen);
if (!kbuf || fread(kbuf, 1, (size_t)klen, kf) != (size_t)klen) {
fprintf(stderr, "mkcapsule: ERROR: cannot read key '%s'\n", key_path);
fclose(kf);
free(kbuf);
return 1;
}
fclose(kf);
if (pkcs8_extract_ed25519_seed(kbuf, (size_t)klen, sign_seed) != 0) {
fprintf(stderr, "mkcapsule: ERROR: '%s' is not a valid PKCS#8 "
"Ed25519 private key\n", key_path);
free(kbuf);
return 1;
}
free(kbuf);
have_sign_seed = 1;
fprintf(stderr, "mkcapsule: signing capsules with key '%s'\n", key_path);
arg0 = 3;
}
if (argc != arg0 + 2) {
fprintf(stderr,
"Usage:\n"
" %s <capsules_dir> <output.c> build capsule C source\n"
" %s [--sign-key <keyfile>] <capsules_dir> <output.c>\n"
" build capsule C source\n"
" %s --lint <capsules_dir> lint all .4th files\n"
" %s --manifest <capsules_dir> print block manifest (stdout)\n"
" %s --manifest <capsules_dir> <out.md> write block manifest to file\n"
@@ -975,7 +1082,7 @@ int main(int argc, char **argv) {
return 1;
}
base_dir = argv[1];
base_dir = argv[arg0];
base_dir_len = strlen(base_dir);
/* Strip trailing slash from base_dir length so relpath strips cleanly */
@@ -983,7 +1090,7 @@ int main(int argc, char **argv) {
base_dir_len--;
}
const char *output_path = argv[2];
const char *output_path = argv[arg0 + 1];
fprintf(stderr, "mkcapsule: checking for block conflicts...\n");
if (check_block_conflicts() != 0) {
+172
View File
@@ -0,0 +1,172 @@
/* pkcs8_ed25519.c -- see pkcs8_ed25519.h. */
#define _GNU_SOURCE /* memmem() */
#include "pkcs8_ed25519.h"
#include <string.h>
#include <stdlib.h>
/*
* `openssl genpkey` writes PEM (base64 text between BEGIN/END markers)
* by default, not raw DER -- accept either transparently rather than
* requiring callers to pre-convert with an external tool (consistent
* with this file's whole point: no new host binary dependency). A small,
* self-contained base64 decoder, since none existed anywhere in this
* repo to reuse (checked 2026-08-26).
*
* Returns a malloc'd DER buffer (caller frees) and sets *der_len, or
* NULL if this doesn't look like a well-formed PEM block. Decodes only
* the FIRST "-----BEGIN ... -----" / "-----END ... -----" pair found;
* good enough for a single private key, not a general PEM bundle parser.
*/
static int b64_val(uint8_t c) {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return -1; /* not a base64 char (newline, '=', etc.) */
}
static uint8_t *pem_to_der(const uint8_t *pem, size_t pem_len, size_t *der_len_out) {
const char *begin_marker = "-----BEGIN";
const char *end_marker = "-----END";
const uint8_t *begin = memmem(pem, pem_len, begin_marker, strlen(begin_marker));
if (!begin) return NULL;
const uint8_t *line_end = memchr(begin, '\n', (size_t)(pem + pem_len - begin));
if (!line_end) return NULL;
const uint8_t *body_start = line_end + 1;
const uint8_t *end = memmem(body_start, (size_t)(pem + pem_len - body_start),
end_marker, strlen(end_marker));
if (!end) return NULL;
/* Decode base64 chars between body_start and end, skipping anything
* that isn't a valid base64 character (newlines, stray whitespace). */
size_t max_out = (size_t)(end - body_start); /* over-allocate, safe upper bound */
uint8_t *out = malloc(max_out ? max_out : 1);
if (!out) return NULL;
size_t out_len = 0;
int acc = 0, nbits = 0;
for (const uint8_t *p = body_start; p < end; p++) {
int v = b64_val(*p);
if (v < 0) continue; /* skip newlines and '=' padding */
acc = (acc << 6) | v;
nbits += 6;
if (nbits >= 8) {
nbits -= 8;
out[out_len++] = (uint8_t)((acc >> nbits) & 0xFF);
}
}
*der_len_out = out_len;
return out;
}
typedef struct {
const uint8_t *p;
size_t len;
} der_span_t;
/* Same short-form/long-form DER TLV walker as x509_ed25519.c's der_next()
* -- see that file's comment for the full rationale. Bounds-checked
* against limit at every step; refuses malformed input, never faults. */
static int der_next(const uint8_t **cursor, const uint8_t *limit,
uint8_t *tag_out, der_span_t *content_out) {
const uint8_t *p = *cursor;
if (p >= limit) return -1;
uint8_t tag = *p++;
if (p >= limit) return -1;
uint8_t len_byte = *p++;
size_t len;
if (len_byte & 0x80u) {
uint8_t nbytes = (uint8_t)(len_byte & 0x7Fu);
if (nbytes == 0 || nbytes > 4) return -1;
if ((size_t)(limit - p) < nbytes) return -1;
len = 0;
for (uint8_t i = 0; i < nbytes; i++) len = (len << 8) | *p++;
} else {
len = len_byte;
}
if ((size_t)(limit - p) < len) return -1;
*tag_out = tag;
content_out->p = p;
content_out->len = len;
*cursor = p + len;
return 0;
}
static int pkcs8_extract_ed25519_seed_der(const uint8_t *der, size_t der_len,
uint8_t seed_out[32]) {
if (!der || !seed_out) return -1;
const uint8_t *cur = der;
const uint8_t *end = der + der_len;
uint8_t tag;
der_span_t outer;
/* OneAsymmetricKey ::= SEQUENCE { version INTEGER, privateKeyAlgorithm
* AlgorithmIdentifier, privateKey OCTET STRING, ... } */
if (der_next(&cur, end, &tag, &outer) != 0 || tag != 0x30) return -1;
const uint8_t *cur2 = outer.p;
const uint8_t *limit2 = outer.p + outer.len;
/* version INTEGER -- skip, don't care about the value */
der_span_t version;
if (der_next(&cur2, limit2, &tag, &version) != 0 || tag != 0x02) return -1;
/* privateKeyAlgorithm ::= AlgorithmIdentifier ::= SEQUENCE { OID, params OPTIONAL } */
der_span_t algid;
if (der_next(&cur2, limit2, &tag, &algid) != 0 || tag != 0x30) return -1;
const uint8_t *cur3 = algid.p;
const uint8_t *limit3 = algid.p + algid.len;
der_span_t oid;
if (der_next(&cur3, limit3, &tag, &oid) != 0 || tag != 0x06) return -1;
static const uint8_t ED25519_OID[3] = { 0x2B, 0x65, 0x70 }; /* 1.3.101.112 */
if (oid.len != sizeof(ED25519_OID) ||
memcmp(oid.p, ED25519_OID, sizeof(ED25519_OID)) != 0) {
return -1;
}
/* privateKey ::= OCTET STRING, whose content is itself a DER-encoded
* OCTET STRING (RFC 8410's CurvePrivateKey) wrapping the raw 32-byte
* seed -- double-wrapped, confirmed empirically against a real
* openssl-generated key (2026-08-26), not assumed from the RFC text
* alone: `04 22 04 20 <32 bytes>` (outer OCTET STRING len=34,
* containing inner OCTET STRING len=32). */
der_span_t outer_octet;
if (der_next(&cur2, limit2, &tag, &outer_octet) != 0 || tag != 0x04) return -1;
const uint8_t *cur4 = outer_octet.p;
const uint8_t *limit4 = outer_octet.p + outer_octet.len;
der_span_t inner_octet;
if (der_next(&cur4, limit4, &tag, &inner_octet) != 0 || tag != 0x04) return -1;
if (inner_octet.len != 32) return -1;
memcpy(seed_out, inner_octet.p, 32);
return 0;
}
int pkcs8_extract_ed25519_seed(const uint8_t *data, size_t data_len,
uint8_t seed_out[32]) {
if (!data || !seed_out) return -1;
/* `openssl genpkey`'s default output is PEM, not raw DER -- accept
* either. A PEM block always starts with "-----BEGIN" somewhere near
* the top; anything else is assumed to already be raw DER. */
if (memmem(data, data_len, "-----BEGIN", 10) != NULL) {
size_t der_len = 0;
uint8_t *der = pem_to_der(data, data_len, &der_len);
if (!der) return -1;
int rc = pkcs8_extract_ed25519_seed_der(der, der_len, seed_out);
free(der);
return rc;
}
return pkcs8_extract_ed25519_seed_der(data, data_len, seed_out);
}
+26
View File
@@ -0,0 +1,26 @@
/*
* pkcs8_ed25519.h -- extract the raw 32-byte Ed25519 seed from a
* PKCS#8 DER-encoded private key (RFC 8410 OneAsymmetricKey), for
* mkcapsule's host-side capsule signing step (Milestone 6, Phase 8).
*
* Host-only: this never runs in the kernel (the kernel never signs, see
* ed25519.h). Deliberately its own narrow, from-scratch DER walker, not
* shared with src/starkernel/crypto/x509_ed25519.c -- that file walks a
* full Certificate structure to a *public* key; this one walks the much
* smaller PKCS#8 OneAsymmetricKey structure to a *private* key seed.
* Small enough that duplicating the handful of TLV-walking lines is
* simpler and easier to audit independently than threading a shared
* header between the kernel crypto tree and host build tooling.
*/
#ifndef TOOLS_PKCS8_ED25519_H
#define TOOLS_PKCS8_ED25519_H
#include <stdint.h>
#include <stddef.h>
/* Returns 0 on success (seed_out[32] filled), -1 on any malformed
* encoding or non-Ed25519 algorithm. Never faults on malformed input. */
int pkcs8_extract_ed25519_seed(const uint8_t *der, size_t der_len,
uint8_t seed_out[32]);
#endif /* TOOLS_PKCS8_ED25519_H */