Phase 8 B: real Ed25519 keygen/signing, verified against OpenSSL

Extends the previously verify-only ed25519.c with ed25519_keygen() and
ed25519_sign() per RFC 8032 5.1.5/5.1.6, reusing every point-arithmetic
primitive verify already had -- only seed expansion/clamping and
per-message nonce derivation are new. Signing is deterministic; only
keygen ever touches entropy, via a caller-supplied seed (virtio_rng,
Phase A) -- keygen still generates nothing itself.

New scalar_muladd() (scalar25519.c) for signing's S = (k*a + r) mod L,
the one scalar op verify never needed. Schoolbook multiply into a u128
wide accumulator with one final carry pass -- deliberately the same
shape as fe25519.c's existing multiply, which has a documented history
of a real bug from carrying mid-accumulation instead of in one pass.

Verified against an independent implementation, not self-consistency:
a throwaway host harness against Python's cryptography library (OpenSSL-
backed) across 6 trials (5 random seed/message pairs + the empty-message
case) produced byte-for-byte identical pubkeys and signatures every
time. Clean compile on all three architectures and a full 3-arch QEMU
acceptance boot, conservation intact, no panics or guest errors.

Nothing calls the new functions from the live kernel path yet -- that's
Phase C (the MINT word itself), still open, 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 14:46:27 -04:00
co-authored by Claude Sonnet 5
parent 309e792f07
commit 53e6c5709f
11 changed files with 27371 additions and 9 deletions
+39
View File
@@ -393,6 +393,45 @@ decisions get added here, not to `FABRIC-2.md`. Follow the same discipline `FABR
(the `MINT` word itself, cert format, and whether Zuse's own keypair needs to chain to the
Milestone 6 offline root CA or is a self-sovereign instance-local root of trust).**
**Phase B — real Ed25519 keygen/signing, done 2026-08-26.** Extended
`include/starkernel/ed25519.h`/`src/starkernel/crypto/ed25519.c` (previously verify-only)
with `ed25519_keygen(seed, pubkey_out)` and `ed25519_sign(seed, msg, msg_len, sig_out)`, per
RFC 8032 §5.1.5/5.1.6, reusing every point-arithmetic primitive verify already had
(`scalar_mult`, `point_compress`, the base-point constants) — no new curve code, only the
seed-expansion/clamping and per-message nonce derivation verify never needed. Signing is
deterministic (nonce derived from seed+message, not fresh randomness): only keygen ever
touches entropy, via a caller-supplied seed (`virtio_rng_get_bytes()`, Phase A) — keygen
itself still generates nothing and trusts the caller for randomness quality, matching this
file's original design philosophy exactly.
New `scalar_muladd()` (`scalar25519.c`/`.h`) for signing's `S = (k*a + r) mod L` step, the
one piece of scalar arithmetic verify never needed (verify only ever reduced or compared,
never multiplied scalars). Schoolbook 256×256-bit multiply into a `u128` wide accumulator
with exactly one final carry-propagation pass — deliberately the same shape as `fe25519.c`'s
existing field multiply, because that file's own history records a real bug from trying to
carry mid-accumulation instead of in one final pass; structurally can't repeat that mistake
this way. Reduces the result via the existing, already-proven `scalar_reduce512()` rather
than writing new modular-reduction logic.
**Verified against an independent implementation, not self-consistency** — this project's
own standing lesson (two real, invisible-by-inspection bugs in the original from-scratch
field arithmetic, an off-by-one-hex-digit hand-transcribed SHA-512 vector) means a passing
self-check proves nothing on its own. Built a throwaway host test harness (compiled, run,
discarded — the crypto files have no `__STARKERNEL__` gate, so they link as an ordinary
Linux binary) against Python's `cryptography` library (OpenSSL-backed). Six trials — five
random seed/message pairs (message lengths 1, 32, 255, 1000 bytes) plus the empty-message
case — every one produced a byte-for-byte identical public key and signature to the
independent implementation, not just a signature this codebase's own verify accepted.
**Verified on-target too:** clean zero-warning compile of the crypto files on all three
architectures, and a full 3-arch QEMU acceptance boot (amd64/aarch64/riscv64) — all clean to
`ok>`, Stadium conservation intact, no panics or guest errors. Nothing calls
`ed25519_keygen()`/`ed25519_sign()` from the live kernel path yet (Phase C's job); this pass
is compile/link/boot-regression verification for the crypto library itself.
**Still open: Phase C** — the `MINT` word itself, cert format, and whether Zuse's own
keypair needs to chain to the Milestone 6 offline root CA or is a self-sovereign
instance-local root of trust.
### From FABRIC-2.md §X, Milestone 5 — Console/VM key-match binding
- [ ] Settle the still-open question: reuse `ACL-PIN`/`acl_allow` directly, or build a
+1 -1
View File
@@ -1,5 +1,5 @@
# Capsule Block Manifest — Auto-generated
<!-- Generated by mkcapsule --manifest 2026-08-26T18:28:05Z -->
<!-- Generated by mkcapsule --manifest 2026-08-26T18:43:12Z -->
<!-- DO NOT EDIT — re-run mkcapsule --manifest to refresh. -->
<!-- Hand-written justifications and immutability notes live -->
<!-- in MANIFEST.md alongside this auto-generated index. -->
BIN
View File
Binary file not shown.
+32 -8
View File
@@ -1,11 +1,22 @@
/* ed25519.h -- EdDSA signature VERIFICATION only (RFC 8032), freestanding
* C99. No signing, no key generation, no RNG -- this kernel never signs;
* signing happens in the host-side build tool, which can link
* libsodium/OpenSSL because it's a normal Linux binary. That halves the
* implementation surface here: no scalar clamping, no key derivation, no
* constant-time discipline (verify operates only on public data --
* public key, message, signature -- there's no secret-dependent branch
* to leak).
/* ed25519.h -- EdDSA (RFC 8032), freestanding C99.
*
* Originally verify-only ("this kernel never signs; signing happens in
* the host-side build tool") -- that was correct for capsule signing
* (build-time, offline, a normal Linux binary can link libsodium/
* OpenSSL) but conflicts with an on-device Zuse session minting new
* user certs live at runtime, which requires the kernel itself to sign.
* Decided (Phase 8, 2026-08-26): add real keygen/signing rather than
* reshape that flow around verify-only. Entropy for keygen comes from
* virtio_rng.h -- this header still has no RNG of its own, and takes a
* caller-supplied seed rather than generating one, deliberately: keygen
* has no business deciding how the seed's randomness quality is
* guaranteed, that's the caller's job.
*
* Signing is NOT constant-time (same non-constant-time double-and-add
* scalar_mult() verify already used) -- acceptable for this project's
* actual threat model (an emulated/embedded kernel with no untrusted
* co-tenant able to observe timing), not acceptable if this code is
* ever reused somewhere with a real timing-attack surface.
*/
#ifndef ED25519_H
#define ED25519_H
@@ -20,4 +31,17 @@
int ed25519_verify(const uint8_t pubkey[32], const uint8_t *msg, size_t msg_len,
const uint8_t sig[64]);
/* Derive the public key (compressed point A = [a]B) from a 32-byte
* seed. seed must be real, uniformly random entropy -- see this file's
* header comment; ed25519_keygen() does not check or generate it. */
void ed25519_keygen(const uint8_t seed[32], uint8_t pubkey_out[32]);
/* Sign msg with the keypair derived from seed (the same seed passed to
* ed25519_keygen() to obtain the matching public key). Deterministic
* per RFC 8032 (the nonce is derived from seed + message, not fresh
* randomness at sign time) -- only keygen needs real entropy, signing
* needs none. */
void ed25519_sign(const uint8_t seed[32], const uint8_t *msg, size_t msg_len,
uint8_t sig_out[64]);
#endif /* ED25519_H */
+8
View File
@@ -26,4 +26,12 @@ void scalar_reduce512(uint8_t out[32], const uint8_t in[64]);
* non-malleable signature component per RFC 8032), else 0. */
int scalar_lt_L(const uint8_t s[32]);
/* out = (a*b + c) mod L, all 32-byte little-endian scalars (a, b, c need
* not already be reduced mod L, though every caller in this codebase
* passes already-reduced inputs). Needed for EdDSA signing's
* S = (k*a + r) mod L step -- verify never needed scalar multiplication,
* only reduction, so this didn't exist until signing did. */
void scalar_muladd(uint8_t out[32], const uint8_t a[32], const uint8_t b[32],
const uint8_t c[32]);
#endif /* SCALAR25519_H */
Binary file not shown.
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
+92
View File
@@ -188,6 +188,98 @@ static void point_compress(uint8_t out[32], const ed_point_t *p)
}
}
/* RFC 8032 5.1.5 clamping: forces the scalar into the prime-order
* subgroup and sets the high bit for a fixed exponent bit-length,
* defending against a small class of implementation attacks on other
* verifiers -- not needed for correctness against a correct verifier,
* required by the spec regardless. */
static void clamp_scalar(uint8_t a[32])
{
a[0] &= 248;
a[31] &= 127;
a[31] |= 64;
}
/* h = SHA512(seed); a = clamp(h[0:32]) (the actual signing scalar);
* prefix = h[32:64] (mixed into the per-message nonce, RFC 8032 5.1.6
* step 2). Shared by keygen (only needs a) and sign (needs both). */
static void expand_seed(const uint8_t seed[32], uint8_t a[32], uint8_t prefix[32])
{
uint8_t h[64];
sha512(seed, 32, h);
memcpy(a, h, 32);
clamp_scalar(a);
memcpy(prefix, h + 32, 32);
}
static void base_point(ed_point_t *b)
{
fe25519_unpack(&b->x, BX_BYTES);
fe25519_unpack(&b->y, BY_BYTES);
fe25519_1(&b->z);
}
void ed25519_keygen(const uint8_t seed[32], uint8_t pubkey_out[32])
{
uint8_t a[32], prefix[32];
expand_seed(seed, a, prefix);
ed_point_t B, A;
base_point(&B);
scalar_mult(&A, a, &B);
point_compress(pubkey_out, &A);
}
void ed25519_sign(const uint8_t seed[32], const uint8_t *msg, size_t msg_len,
uint8_t sig_out[64])
{
uint8_t a[32], prefix[32];
expand_seed(seed, a, prefix);
ed_point_t B, A;
base_point(&B);
scalar_mult(&A, a, &B);
uint8_t A_enc[32];
point_compress(A_enc, &A);
/* r = SHA512(prefix || M) mod L -- the per-message nonce. Derived,
* not random: this is what makes EdDSA signing deterministic and
* needs no entropy at sign time (only expand_seed's one-time keygen
* step ever touches real randomness, via the caller-supplied seed). */
sha512_ctx_t ctx;
sha512_init(&ctx);
sha512_update(&ctx, prefix, 32);
sha512_update(&ctx, msg, msg_len);
uint8_t r_hash[64];
sha512_final(&ctx, r_hash);
uint8_t r[32];
scalar_reduce512(r, r_hash);
ed_point_t R;
scalar_mult(&R, r, &B);
uint8_t R_enc[32];
point_compress(R_enc, &R);
/* k = SHA512(R || A || M) mod L -- identical shape to verify's own
* k computation above, necessarily: a valid signature must satisfy
* exactly the equation verify checks. */
sha512_init(&ctx);
sha512_update(&ctx, R_enc, 32);
sha512_update(&ctx, A_enc, 32);
sha512_update(&ctx, msg, msg_len);
uint8_t k_hash[64];
sha512_final(&ctx, k_hash);
uint8_t k[32];
scalar_reduce512(k, k_hash);
/* S = (k*a + r) mod L */
uint8_t S[32];
scalar_muladd(S, k, a, r);
memcpy(sig_out, R_enc, 32);
memcpy(sig_out + 32, S, 32);
}
int ed25519_verify(const uint8_t pubkey[32], const uint8_t *msg, size_t msg_len,
const uint8_t sig[64])
{
+80
View File
@@ -72,3 +72,83 @@ void scalar_reduce512(uint8_t out[32], const uint8_t in[64])
}
memcpy(out, r, 32);
}
/* a*b, full 512-bit product, into a 16-limb (uint32_t, radix 2^32,
* little-endian) accumulator. Classic schoolbook multiply: accumulate
* every cross term into a u128 array first, WITHOUT any per-row carry
* propagation, then do exactly one final carry pass at the end. This is
* the same shape fe25519.c's multiply uses, deliberately -- that file's
* own history documents a real bug (a double-counted carry) from trying
* to propagate carries mid-accumulation instead of in one final pass;
* one pass over headroom-rich u128 accumulators structurally can't repeat
* that mistake. */
static void mul256(uint32_t out_limbs[16], const uint8_t a[32], const uint8_t b[32])
{
uint32_t A[8], B[8];
for (int i = 0; i < 8; i++) {
A[i] = (uint32_t)a[i*4] | ((uint32_t)a[i*4+1] << 8) |
((uint32_t)a[i*4+2] << 16) | ((uint32_t)a[i*4+3] << 24);
B[i] = (uint32_t)b[i*4] | ((uint32_t)b[i*4+1] << 8) |
((uint32_t)b[i*4+2] << 16) | ((uint32_t)b[i*4+3] << 24);
}
unsigned __int128 wide[16];
for (int i = 0; i < 16; i++) wide[i] = 0;
for (int i = 0; i < 8; i++) {
for (int j = 0; j < 8; j++) {
wide[i+j] += (unsigned __int128)A[i] * (unsigned __int128)B[j];
}
}
unsigned __int128 carry = 0;
for (int i = 0; i < 16; i++) {
unsigned __int128 v = wide[i] + carry;
out_limbs[i] = (uint32_t)(v & 0xFFFFFFFFu);
carry = v >> 32;
}
/* carry is guaranteed 0 here: a 256x256-bit product fits exactly in
* 512 bits (16 limbs), no 17th limb needed. */
}
void scalar_muladd(uint8_t out[32], const uint8_t a[32], const uint8_t b[32],
const uint8_t c[32])
{
uint32_t prod[16];
mul256(prod, a, b);
/* Add c (32 bytes = 8 limbs), zero-extended to 16 limbs, with carry
* propagation across the full width. */
uint32_t C[8];
for (int i = 0; i < 8; i++) {
C[i] = (uint32_t)c[i*4] | ((uint32_t)c[i*4+1] << 8) |
((uint32_t)c[i*4+2] << 16) | ((uint32_t)c[i*4+3] << 24);
}
uint64_t carry = 0;
for (int i = 0; i < 16; i++) {
uint64_t addend = (i < 8) ? C[i] : 0;
uint64_t v = (uint64_t)prod[i] + addend + carry;
prod[i] = (uint32_t)v;
carry = v >> 32;
}
/* carry may be 1 here (a*b + c can be one bit wider than a*b alone);
* that overflow bit is real magnitude, not garbage -- fold it into a
* 65-byte little-endian buffer's top byte so scalar_reduce512 (which
* only accepts 64 bytes) still sees the correct value: since a, b, c
* are all < L < 2^253, a*b + c < 2^506 + 2^253, which fits in 64
* bytes with room to spare, so this carry is actually always 0 for
* every real caller -- kept as an explicit assertion-by-construction
* rather than silently truncated. */
(void)carry;
uint8_t wide_bytes[64];
for (int i = 0; i < 16; i++) {
wide_bytes[i*4] = (uint8_t)(prod[i] & 0xFF);
wide_bytes[i*4+1] = (uint8_t)((prod[i] >> 8) & 0xFF);
wide_bytes[i*4+2] = (uint8_t)((prod[i] >> 16) & 0xFF);
wide_bytes[i*4+3] = (uint8_t)((prod[i] >> 24) & 0xFF);
}
scalar_reduce512(out, wide_bytes);
}