diff --git a/FABRIC-3.md b/FABRIC-3.md index d4eadf3..06ddcd0 100644 --- a/FABRIC-3.md +++ b/FABRIC-3.md @@ -696,6 +696,54 @@ decisions get added here, not to `FABRIC-2.md`. Follow the same discipline `FABR - [ ] Implement magic-number-based content-type detection (Section U item 14) — a shared primitive, also usable for Milestone 4's foreign-drive check. +- [x] **Root CA + snakeoil intermediate generated 2026-08-26**, entirely offline, in a sibling + directory outside this repo (`/home/rajames/CLionProjects/lithosananke-ca/`, not tracked by + git here). Ed25519, OpenSSL 3.5.5. Root: self-signed, 20-year validity (2026–2046), + `CN=LithosAnanke Root CA`. Intermediate: a real CA-signed cert (not self-signed despite the + name), 10-year validity, `CA:TRUE, pathlen:0` (can sign capsules, can't mint further + intermediates), chain verified (`openssl verify` returns `OK`). Both private keys `chmod + 600`. The root key never touches this repo or any kernel code, per the design's own + requirement. + +- [x] **Snakeoil intermediate embedded as a capsule, 2026-08-26.** Exported to DER + (`capsules/pki/snakeoil-intermediate.der`, 418 bytes) and dropped under `capsules/` — + confirmed the font-capsule precedent needed zero new infrastructure: `mkcapsule`'s + `process_file()` embeds any non-`.4th` file verbatim already. Shows up as capsule + `pki:snakeoil-intermediate.der` in the generated capsule table (38 capsules total, up from + 37) — retrieve via `capsule_find_by_name()` + `capsule_get_payload()`, never + `capsule_exec_payload()` (it's a passive data blob, not executable capsule code). + +- [x] **Minimal DER/X.509 parser written and independently verified, 2026-08-26.** New + `include/starkernel/x509_ed25519.h` + `src/starkernel/crypto/x509_ed25519.c`: + `x509_extract_ed25519_pubkey()`, a from-scratch, narrow DER walker (not a general ASN.1/ + X.509 parser, per this milestone's own design decision) — walks `Certificate → + TBSCertificate → SubjectPublicKeyInfo`, handles the optional `[0] EXPLICIT Version` field + (present on v3 certs), verifies the `AlgorithmIdentifier` OID is exactly `1.3.101.112` + (RFC 8410 Ed25519) rather than assuming, and extracts the raw 32-byte key from the trailing + `BIT STRING`. Handles both short-form and long-form DER lengths (a real cert with v3 + extensions routinely exceeds the 127-byte short-form limit). Every step bounds-checked + against the buffer end — refuses malformed input, never faults. + + **Verified against ground truth, not self-consistency:** run against the real embedded + `snakeoil-intermediate.der`, the extracted 32-byte key matched `openssl pkey -pubin -text`'s + own reported public key byte-for-byte. Refusal path verified too: truncated input, 10 random + garbage bytes, an empty file, and a real RSA certificate (algorithm-mismatch case, not just + structural malformation) all correctly return failure rather than misreading or crashing. + Compiles clean on all three kernel architectures (no `__STARKERNEL__` guard needed — same + freestanding-safe shape as the other crypto files). + + **Still open:** the signing step in `mkcapsule` (needs a new `sig[64]` field on + `CapsuleEntry` and a parallel emitted array in `capsule_generated.c`, since `CapsuleDesc` + itself has no spare bytes — confirmed exactly 64 bytes, every field used), wiring + `ed25519_verify()` into the three `capsule_validate()` call sites in `capsule_birth.c` + (**decided: land as WARN-only first, prove correct on all three architectures against both a + valid and a deliberately-corrupted capsule, then flip to hard-refuse in a separate step** — + a bug here has a much larger blast radius than anything else in Phase 8, since a false + refusal on Mama's own capsule means no `ok>` at all, on any architecture), and the + signature-status column on `capsules/BLOCK_MAP.md` (confirmed the real, live manifest target + — `capsules/MANIFEST_AUTO.md` is stale/dead, not regenerated since 2026-07-05, flag as docs + drift rather than a real target). + ### From FABRIC-2.md §X, Milestone 7 — Contributor capsules / trust tiers - [ ] Create the `capsules/contrib/` directory (mechanically trivial, matches existing diff --git a/capsules/BLOCK_MAP.md b/capsules/BLOCK_MAP.md index a67be6c..0f39dab 100644 --- a/capsules/BLOCK_MAP.md +++ b/capsules/BLOCK_MAP.md @@ -1,5 +1,5 @@ # Capsule Block Manifest — Auto-generated - + diff --git a/capsules/pki/snakeoil-intermediate.der b/capsules/pki/snakeoil-intermediate.der new file mode 100644 index 0000000..cc4623c Binary files /dev/null and b/capsules/pki/snakeoil-intermediate.der differ diff --git a/include/starkernel/x509_ed25519.h b/include/starkernel/x509_ed25519.h new file mode 100644 index 0000000..0ad81fa --- /dev/null +++ b/include/starkernel/x509_ed25519.h @@ -0,0 +1,30 @@ +/* + * x509_ed25519.h -- extract a raw Ed25519 public key from a DER-encoded + * X.509 certificate's SubjectPublicKeyInfo (RFC 8410). + * + * Deliberately NOT a general ASN.1/X.509 parser (Milestone 6 decision, + * FABRIC-2.md): walks exactly as far into the DER structure as needed + * to reach SubjectPublicKeyInfo, verifies its AlgorithmIdentifier OID is + * Ed25519 (1.3.101.112) and rejects anything else, then returns the raw + * 32-byte key from the trailing BIT STRING. No signature verification, + * no chain validation, no extension parsing -- this only answers "what + * public key does this cert claim to hold," matching exactly what + * ed25519_verify() needs as input. + * + * Freestanding C99, no libc beyond memcmp/memcpy (already provided by + * src/starkernel/vm/host/shim.c in the kernel build). + */ +#ifndef STARKERNEL_X509_ED25519_H +#define STARKERNEL_X509_ED25519_H + +#include +#include + +/* Returns 0 on success (pubkey_out[32] filled), -1 on any malformed + * encoding, unexpected structure, or non-Ed25519 algorithm. Never + * faults on malformed input -- every DER length/tag is bounds-checked + * against der_len before use. */ +int x509_extract_ed25519_pubkey(const uint8_t *der, size_t der_len, + uint8_t pubkey_out[32]); + +#endif /* STARKERNEL_X509_ED25519_H */ diff --git a/src/starkernel/crypto/x509_ed25519.c b/src/starkernel/crypto/x509_ed25519.c new file mode 100644 index 0000000..d4f9b2b --- /dev/null +++ b/src/starkernel/crypto/x509_ed25519.c @@ -0,0 +1,124 @@ +/* x509_ed25519.c -- see x509_ed25519.h. */ +#include "starkernel/x509_ed25519.h" +#include + +typedef struct { + const uint8_t *p; + size_t len; +} der_span_t; + +/* + * Parse a single DER TLV starting at *cursor (must be < limit). Handles + * short-form length (< 128, one byte) and long-form length (0x80 | N, + * followed by N big-endian length bytes, N in 1..4 -- a real X.509 cert + * with extensions routinely exceeds the 127-byte short-form limit). + * Rejects indefinite-length (0x80 alone) and anything claiming a length + * that would run past limit -- every step is bounds-checked, malformed + * input is refused, never faulted on. + * + * On success: advances *cursor past this whole TLV, returns 0, sets + * *tag_out to the raw tag byte and *content_out to a span over just the + * VALUE bytes (not the tag/length prefix). + */ +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; /* indefinite-length or unreasonably large */ + 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; +} + +int x509_extract_ed25519_pubkey(const uint8_t *der, size_t der_len, + uint8_t pubkey_out[32]) { + if (!der || !pubkey_out) return -1; + + const uint8_t *cur = der; + const uint8_t *end = der + der_len; + uint8_t tag; + der_span_t cert; + + /* Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue } */ + if (der_next(&cur, end, &tag, &cert) != 0 || tag != 0x30) return -1; + + /* tbsCertificate ::= SEQUENCE { ... } -- first child of Certificate */ + const uint8_t *tbs_cur = cert.p; + const uint8_t *tbs_limit = cert.p + cert.len; + der_span_t tbs; + if (der_next(&tbs_cur, tbs_limit, &tag, &tbs) != 0 || tag != 0x30) return -1; + + const uint8_t *cur2 = tbs.p; + const uint8_t *limit2 = tbs.p + tbs.len; + + /* version [0] EXPLICIT Version DEFAULT v1 -- present (context- + * specific constructed tag 0xA0) for v2/v3 certs, absent for v1. + * Peek: if present, consume it; if not, leave cur2 where it is so + * the next field (serialNumber) is read correctly either way. */ + { + const uint8_t *peek = cur2; + uint8_t ptag; + der_span_t pcontent; + if (der_next(&peek, limit2, &ptag, &pcontent) == 0 && ptag == 0xA0) { + cur2 = peek; + } + } + + /* Skip serialNumber, signature (AlgorithmIdentifier), issuer, + * validity, subject -- exactly 5 fields, none of which this parser + * needs the content of, only their length to skip past them. */ + for (int i = 0; i < 5; i++) { + der_span_t skip; + if (der_next(&cur2, limit2, &tag, &skip) != 0) return -1; + } + + /* subjectPublicKeyInfo ::= SEQUENCE { algorithm, subjectPublicKey } */ + der_span_t spki; + if (der_next(&cur2, limit2, &tag, &spki) != 0 || tag != 0x30) return -1; + + const uint8_t *cur3 = spki.p; + const uint8_t *limit3 = spki.p + spki.len; + + /* algorithm ::= AlgorithmIdentifier ::= SEQUENCE { algorithm OID, parameters ANY OPTIONAL } */ + der_span_t algid; + if (der_next(&cur3, limit3, &tag, &algid) != 0 || tag != 0x30) return -1; + + const uint8_t *cur4 = algid.p; + const uint8_t *limit4 = algid.p + algid.len; + der_span_t oid; + if (der_next(&cur4, limit4, &tag, &oid) != 0 || tag != 0x06) return -1; + + /* OID 1.3.101.112 (RFC 8410 Ed25519), DER-encoded: 43 (=40*1+3), 101, 112, + * each arc after the first pair fitting in one byte (both < 128). */ + static const uint8_t ED25519_OID[3] = { 0x2B, 0x65, 0x70 }; + if (oid.len != sizeof(ED25519_OID) || + memcmp(oid.p, ED25519_OID, sizeof(ED25519_OID)) != 0) { + return -1; /* not an Ed25519 key -- refuse, don't guess */ + } + + /* subjectPublicKey ::= BIT STRING -- 1 unused-bits byte (must be 0 + * for a byte-aligned 32-byte key) followed by the raw key itself. */ + der_span_t bitstr; + if (der_next(&cur3, limit3, &tag, &bitstr) != 0 || tag != 0x03) return -1; + if (bitstr.len != 33 || bitstr.p[0] != 0x00) return -1; + + memcpy(pubkey_out, bitstr.p + 1, 32); + return 0; +}