From 8e9e28e357715df02e9c08d0287e47d943e3cebb Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Tue, 2 Jun 2026 07:19:18 +0900 Subject: [PATCH] lib/crypto/sha256: port SHA-256 over hash::hash; NIST test (989) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of ref/hare/crypto/sha256/sha256.ha — block-processed [64]u8 chunks, u32 modular arithmetic, hash::hash + io.writer surface. The state embeds hash.hash (inline vtable at offset 0); the vtable + sum/ reset slots are wired post-construction (base64/memio convention). u32 WRAPPING + vtable dispatch CONFIRMED CLEAN: all NIST vectors verify byte-identical — empty, "abc", the 56-byte block-boundary case, and the one-million-'a' multi-block stream (1000-byte chunks across many blocks, stressing write()'s partial-block carry). cgen truncates u32 add/shift/ rotate to 32 bits correctly; no masking workaround needed. Semantics-preserving spelling divergences (slice-copy as byte loops, close()/digest loops) are noted at-site per CLAUDE.md rule 5/13. ONE BEHAVIORAL DIVERGENCE, blocked on a cgen bug (flagged for ken/drew): Hare's sum() snapshots the state (`let copy = *h`) so it is re-entrant. That deref-copy of an array-containing struct miscompiles in ww cgen (copied array fields come back zeroed). So sum() runs on the live state and is SINGLE-SHOT until the cgen fix lands; every current caller does one terminal sum(), so the digests are unaffected. Minimal repro: type t = struct { h: [4]u32 }; let c: t = *(&s); // c.h reads back wrong A sibling bug (array return-by-value zeroes the result) was also found and is avoided in the test's buffer-based helper. Both filed for ken. The hash/crypto modules are dead-imported (no selfhost combined.ww regen). 9xx test numbers are full, so the run-test shares the 989 prefix with siphash (distinct `short` name; 949_* multi-file precedent). --- Makefile | 6 +- lib/crypto/sha256/sha256.ww | 330 +++++++++++++++++++++++++++++++ lib/crypto/sha256/sha256_test.ww | 109 ++++++++++ test/wcc/989_sha256_run.c | 52 +++++ 4 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 lib/crypto/sha256/sha256.ww create mode 100644 lib/crypto/sha256/sha256_test.ww create mode 100644 test/wcc/989_sha256_run.c diff --git a/Makefile b/Makefile index af1b72ef..bf26697e 100644 --- a/Makefile +++ b/Makefile @@ -396,7 +396,7 @@ TESTS = $(BIN)/test_smoke $(BIN)/test_lex $(BIN)/test_parse $(BIN)/test_check \ $(BIN)/test_base32_run $(BIN)/test_base64_run \ $(BIN)/test_adler32_run $(BIN)/test_crc16_run \ $(BIN)/test_crc32_run $(BIN)/test_crc64_run \ - $(BIN)/test_siphash_run \ + $(BIN)/test_siphash_run $(BIN)/test_sha256_run \ $(BIN)/test_checked_run \ $(BIN)/test_floatarr_run \ $(BIN)/test_deref_narrow_run \ @@ -1565,6 +1565,10 @@ $(BIN)/test_siphash_run: test/wcc/989_siphash_run.c $(BIN)/ww $(BIN)/w6c \ $(BIN)/w6a $(BIN)/w6l $(LIB)/libwwrt.a | $(BIN) $(CC) $(CFLAGS) -o $@ $< +$(BIN)/test_sha256_run: test/wcc/989_sha256_run.c $(BIN)/ww $(BIN)/w6c \ + $(BIN)/w6a $(BIN)/w6l $(LIB)/libwwrt.a | $(BIN) + $(CC) $(CFLAGS) -o $@ $< + $(BIN)/test_bufio_run: test/wcc/998_bufio_run.c $(BIN)/ww $(BIN)/w6c \ $(BIN)/w6a $(BIN)/w6l $(LIB)/libwwrt.a | $(BIN) $(CC) $(CFLAGS) -o $@ $< diff --git a/lib/crypto/sha256/sha256.ww b/lib/crypto/sha256/sha256.ww new file mode 100644 index 00000000..78fa3346 --- /dev/null +++ b/lib/crypto/sha256/sha256.ww @@ -0,0 +1,330 @@ +// crypto/sha256 — SHA-256 (FIPS 180-4) over the hash::hash interface. +// Port of ref/hare/crypto/sha256/sha256.ha. The digest is computed +// 64-byte block at a time; partial writes buffer in `x` until a full +// block accumulates, and [[sum]] appends the 0x80/zero/bit-length +// padding before reading out the eight state words big-endian. +// +// u32 WRAPPING. SHA-256 is defined over 32-bit modular arithmetic. ww's +// `uint` is a 64-bit machine word, so this module uses `u32` throughout +// (matching Hare) and relies on cgen truncating u32 add/shift/rotate to +// 32 bits. The NIST vectors in sha256_test.ww are the oracle: a digest +// mismatch would mean cgen promoted a u32 op to 64-bit without wrapping +// (a cgen bug to STOP+report, NOT to mask here). +// +// DIVERGENCES from sha256.ha: +// - sum() is SINGLE-SHOT, not re-entrant: Hare's state-snapshot copy +// hits an array-field deref-copy cgen bug (see [[sumfn]]). This is +// the one BEHAVIORAL divergence (blocked on a filed cgen fix); the +// rest below are semantics-preserving spellings. +// - `state` embeds [[hash.hash]], whose first field is an inline +// io.vtable (not Hare's `stream: io::stream` pointer); see +// lib/hash/hash.ww. The vtable slots are wired post-construction in +// [[sha256]] rather than pointing at a module-level const vtable +// (the base64/memio/io convention; ww has no const-vtable-of-fn-ptrs +// idiom). +// - slice-to-slice copy assignment (`h.x[a..b] = b[..n]`) is spelled +// as a byte loop — ww has no slice-copy assignment (cf lib/memio's +// flat-field note). Same observable effect. +// - close() zeros the `h` state words with a loop instead of Hare's +// `(s.h[..]: *[*]u8)[..len*size(u32)]` reinterpret + bytes::zero — +// ww avoids the `*[*]u8` cast; bytes.zero still clears `x`. +// - the eight beputu32 digest writes are a loop (stride routed through +// size(u32) per CLAUDE.md rule 13) rather than Hare's unrolled 8. +package sha256; + +import bytes; +import crypto.math; +import endian; +import hash; +import io; +import os; + +// ref/hare/crypto/sha256/sha256.ha:11. +export def SZ: size = 32; + +// ref/hare/crypto/sha256/sha256.ha:14. +export def BLOCKSZ: size = 64; + +// ref/hare/crypto/sha256/sha256.ha:17-24. u32 suffixes: these scalar +// defs don't ride #251 array-element narrowing, and four exceed 2^31. +def init0: u32 = 0x6A09E667u32; +def init1: u32 = 0xBB67AE85u32; +def init2: u32 = 0x3C6EF372u32; +def init3: u32 = 0xA54FF53Au32; +def init4: u32 = 0x510E527Fu32; +def init5: u32 = 0x9B05688Cu32; +def init6: u32 = 0x1F83D9ABu32; +def init7: u32 = 0x5BE0CD19u32; + +// ref/hare/crypto/sha256/sha256.ha:26-38. The round constants (first 32 +// bits of the fractional parts of the cube roots of the first 64 +// primes). Bare hex literals narrow to u32 via #251 array-element +// narrowing (same as base64's decmap). +def k: [64]u32 = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, + 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +// ref/hare/crypto/sha256/sha256.ha:40-46. Embeds [[hash.hash]] (whose +// first field is the inline vtable) so `&st` casts to `*hash.hash` and +// `&st.vt` to `io.stream`. `h` = the eight running state words, `x` = +// the partial-block buffer, `nx` = bytes buffered in `x`, `ln` = total +// bytes written. +export type state = struct { + hash.hash, + h: [8]u32, + x: [64]u8, // BLOCKSZ; array dims require an integer literal + nx: size, + ln: size, +}; + +// sha256 — construct a SHA-256 hash. ref/hare/crypto/sha256/sha256.ha:58-69. +// Wires the vtable + interface fn-ptr slots inline (base64/memio +// convention) then resets to the initial state. Returned BY VALUE; the +// caller passes `&st` (as `*hash.hash`) to the hash dispatchers. +export fn sha256() state = { + let sha: state; + sha.vt.writer = (&writefn): *io.writer; + sha.vt.closer = (&closefn): *io.closer; + sha.sum = (&sumfn): *fn(h: *hash.hash, buf: []u8) void; + sha.reset = (&resetfn): *fn(h: *hash.hash) void; + sha.sz = SZ; + sha.bsz = BLOCKSZ; + hash.reset((&sha): *hash.hash); + return sha; +}; + +// ref/hare/crypto/sha256/sha256.ha:71-83. +fn resetfn(h: *hash.hash) void = { + let st: *state = h: *state; + st.h[0] = init0; + st.h[1] = init1; + st.h[2] = init2; + st.h[3] = init3; + st.h[4] = init4; + st.h[5] = init5; + st.h[6] = init6; + st.h[7] = init7; + st.nx = 0: size; + st.ln = 0: size; +}; + +// writefn — the io.writer slot. ref/hare/crypto/sha256/sha256.ha:85-113. +// Buffers a partial block in `x`, processes every whole block inline, +// and stashes the trailing remainder. Returns the input byte count +// (never errors). Slice copies are byte loops (header divergence). +fn writefn(s: io.stream, buf: []u8) (size | io.error) = { + let st: *state = s: *state; + let b: []u8 = buf; + let total: size = b.len: size; + st.ln += total; + let bs: i32 = BLOCKSZ: i32; + + if (st.nx > 0: size) { + let avail: size = BLOCKSZ - st.nx; + let take: size = avail; + if ((b.len: size) <= avail) { take = b.len: size; }; + let base: i32 = st.nx: i32; + let ti: i32 = take: i32; + let i: i32 = 0; + for (i < ti) { + st.x[base + i] = b[i]; + i += 1; + }; + st.nx += take; + if (st.nx == BLOCKSZ) { + block(st, st.x[0:bs]); + st.nx = 0: size; + }; + b = b[ti:b.len]; + }; + + if ((b.len: size) >= BLOCKSZ) { + let mask: i32 = bs - 1; + let nfull: i32 = b.len & ~mask; + block(st, b[0:nfull]); + b = b[nfull:b.len]; + }; + + if (b.len > 0) { + let i: i32 = 0; + for (i < b.len) { + st.x[i] = b[i]; + i += 1; + }; + st.nx = b.len: size; + }; + return total; +}; + +// sumfn — finalize and emit the digest. ref/hare/crypto/sha256/sha256.ha:115-143. +// +// DIVERGENCE (re-entrancy / blocked on a cgen bug). Hare snapshots the +// state — `let copy = *h; let h = ©` — so sum() is non-destructive +// and the caller can keep writing afterwards. That deref-copy of an +// array-containing struct miscompiles in ww cgen (the copied `h`/`x` +// arrays come back zeroed; minimal repro: `type t = struct { h: [4]u32 }; +// let c: t = *(&s);` reads c.h back wrong — array-field + pointer-deref +// copy, a sibling of the #135/#252 array-field cgen family). Filed for +// ken; flagged for a drew fidelity ruling. Until it lands, sum() runs on +// the live state and is therefore SINGLE-SHOT: writing or summing again +// after a sum() yields wrong results. Every current caller does exactly +// one terminal sum(), so the NIST digests are unaffected (all vectors, +// incl. the multi-block million-'a' stream, verify byte-identical). The +// final close() still wipes the live state per Hare. +fn sumfn(h: *hash.hash, buf: []u8) void = { + let st: *state = h: *state; + + let ln: size = st.ln; + let tmp: [64]u8 = [0...]; + tmp[0] = 0x80u8; + + // Pad so the message + 0x80 + zeros ends 8 bytes (the bit-length + // field) shy of a block boundary. + let lenbytes: size = size(u64); + let thresh: size = BLOCKSZ - lenbytes; + let m: size = ln % BLOCKSZ; + let pad: size = thresh - m; + if (m >= thresh) { pad = (BLOCKSZ + thresh) - m; }; + let padn: i32 = pad: i32; + match (writefn(&st.vt, tmp[0:padn])) { + case let z: size => void; + case let e: io.error => abort("sha256.sum: pad write errored"); + }; + + ln <<= 3; // bytes -> bits + endian.beputu64(tmp[0:8], ln: u64); + let lb: i32 = lenbytes: i32; + match (writefn(&st.vt, tmp[0:lb])) { + case let z: size => void; + case let e: io.error => abort("sha256.sum: length write errored"); + }; + + os.assert(st.nx == 0: size, "sha256.sum: residual partial block"); + + let stride: i32 = size(u32): i32; + let i: i32 = 0; + for (i < 8) { + let off: i32 = i * stride; + endian.beputu32(buf[off:off + stride], st.h[i]); + i += 1; + }; + + hash.close(h); +}; + +// block — process every whole 64-byte block in `buf`. +// ref/hare/crypto/sha256/sha256.ha:146-213. Param renamed `st` (Hare's +// `h`) so the 8th compression working var can stay `hh` without +// shadowing the state pointer. +fn block(st: *state, buf: []u8) void = { + let w: [64]u32 = [0...]; + let h0: u32 = st.h[0]; + let h1: u32 = st.h[1]; + let h2: u32 = st.h[2]; + let h3: u32 = st.h[3]; + let h4: u32 = st.h[4]; + let h5: u32 = st.h[5]; + let h6: u32 = st.h[6]; + let h7: u32 = st.h[7]; + let bs: i32 = BLOCKSZ: i32; + let b: []u8 = buf; + + for (b.len >= bs) { + let i: i32 = 0; + for (i < 16) { + let j: i32 = i * 4; + w[i] = ((b[j]: u32) << 24u32) + | ((b[j + 1]: u32) << 16u32) + | ((b[j + 2]: u32) << 8u32) + | (b[j + 3]: u32); + i += 1; + }; + + i = 16; + for (i < 64) { + let v1: u32 = w[i - 2]; + let t1: u32 = math.rotr32(v1, 17) + ^ math.rotr32(v1, 19) + ^ (v1 >> 10u32); + let v2: u32 = w[i - 15]; + let t2: u32 = math.rotr32(v2, 7) + ^ math.rotr32(v2, 18) + ^ (v2 >> 3u32); + w[i] = t1 + w[i - 7] + t2 + w[i - 16]; + i += 1; + }; + + let a: u32 = h0; + let bb: u32 = h1; + let c: u32 = h2; + let d: u32 = h3; + let e: u32 = h4; + let f: u32 = h5; + let g: u32 = h6; + let hh: u32 = h7; + i = 0; + for (i < 64) { + let t1: u32 = hh + + (math.rotr32(e, 6) + ^ math.rotr32(e, 11) + ^ math.rotr32(e, 25)) + + ((e & f) ^ (~e & g)) + k[i] + w[i]; + let t2: u32 = (math.rotr32(a, 2) + ^ math.rotr32(a, 13) + ^ math.rotr32(a, 22)) + + ((a & bb) ^ (a & c) ^ (bb & c)); + hh = g; + g = f; + f = e; + e = d + t1; + d = c; + c = bb; + bb = a; + a = t1 + t2; + i += 1; + }; + + h0 += a; + h1 += bb; + h2 += c; + h3 += d; + h4 += e; + h5 += f; + h6 += g; + h7 += hh; + + b = b[bs:b.len]; + }; + + st.h[0] = h0; + st.h[1] = h1; + st.h[2] = h2; + st.h[3] = h3; + st.h[4] = h4; + st.h[5] = h5; + st.h[6] = h6; + st.h[7] = h7; +}; + +// closefn — wipe sensitive state. ref/hare/crypto/sha256/sha256.ha:215-219. +// h zeroed via a loop (header divergence); x via bytes.zero array-decay. +fn closefn(s: io.stream) (void | io.error) = { + let st: *state = s: *state; + let i: i32 = 0; + for (i < 8) { + st.h[i] = 0u32; + i += 1; + }; + bytes.zero(st.x); + return void; +}; diff --git a/lib/crypto/sha256/sha256_test.ww b/lib/crypto/sha256/sha256_test.ww new file mode 100644 index 00000000..a4dc1612 --- /dev/null +++ b/lib/crypto/sha256/sha256_test.ww @@ -0,0 +1,109 @@ +// sha256_test — exercises lib/crypto/sha256 against the standard NIST +// SHA-256 vectors (FIPS 180-4 examples + the classic "one million a's"). +// Run with `out/bin/ww run lib/crypto/sha256/sha256_test.ww`. +// +// The digest is the cgen-correctness oracle for u32 wrapping arithmetic +// + the hash-vtable dispatch: any u32-overflow / rotate miscompile shows +// up as a byte mismatch. Same signalled-then-fail()-with-+10 pattern as +// the rest of the 9xx stdlib tests; the non-zero exit pinpoints the +// failing case. Expected digests come through the (separately tested) +// hex.decodestr so the vectors stay readable. +package sha256; + +import bytes; +import crypto.sha256; +import errors; +import hash; +import encoding.hex; +import strings; +import os; + +let signalled: i32 = 0; +fn fail() void = { os.exit(signalled + 10); }; + +// dohash — one-shot hash of `msg` into the caller's `out` (>= 32 bytes). +// Mirrors the sum()-writes-into-a-buffer API (no array-by-value return). +fn dohash(msg: []u8, out: []u8) void = { + let st: sha256.state = sha256.sha256(); + let h: *hash.hash = (&st): *hash.hash; + hash.write(h, msg); + hash.sum(h, out); +}; + +fn checkbytes(got: []u8, want: str) void = { + match (hex.decodestr(want)) { + case let w: []u8 => { + if (!bytes.equal(got, w)) { fail(); }; + }; + case let e: errors.invalid => fail(); + }; +}; + +fn check(msg: []u8, want: str) void = { + let out: [32]u8; + dohash(msg, out[0:32]); + checkbytes(out[0:32], want); +}; + +// FIPS 180-4 Appendix B.1/B.2/B.3 vectors. + +@test fn empty() void = { + let e: [1]u8; + check(e[0:0], + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); +}; + +@test fn abc() void = { + check(strings.toutf8("abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); +}; + +// 56-byte message: crosses no block boundary but lands exactly on the +// padding edge (56 == BLOCKSZ - 8), the worst case for the pad length +// branch in sum(). +@test fn twoblockpad() void = { + check(strings.toutf8( + "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"), + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"); +}; + +// One million 'a' fed 1000 bytes at a time. 1000 is not a multiple of +// BLOCKSZ, so this drives the partial-block carry in write() across many +// calls and many full blocks — the strongest streaming + u32-wrapping +// stress in the set. +@test fn millionas() void = { + let st: sha256.state = sha256.sha256(); + let h: *hash.hash = (&st): *hash.hash; + let chunk: [1000]u8; + let i: i32 = 0; + for (i < 1000) { + chunk[i] = 'a': u8; + i += 1; + }; + i = 0; + for (i < 1000) { + hash.write(h, chunk[0:1000]); + i += 1; + }; + let out: [32]u8; + hash.sum(h, out[0:32]); + checkbytes(out[0:32], + "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"); +}; + +// sz()/bsz() report the SHA-256 constants regardless of state. +@test fn sizes() void = { + let st: sha256.state = sha256.sha256(); + let h: *hash.hash = (&st): *hash.hash; + if (hash.sz(h) != 32: size) { fail(); }; + if (hash.bsz(h) != 64: size) { fail(); }; +}; + +export fn main() i32 = { + signalled = 1; empty(); + signalled = 2; abc(); + signalled = 3; twoblockpad(); + signalled = 4; millionas(); + signalled = 5; sizes(); + return 0; +}; diff --git a/test/wcc/989_sha256_run.c b/test/wcc/989_sha256_run.c new file mode 100644 index 00000000..89666c08 --- /dev/null +++ b/test/wcc/989_sha256_run.c @@ -0,0 +1,52 @@ +/* + * 989_sha256_run — execute the lib/crypto/sha256 @test fixture under the + * C-side `ww run` driver and assert exit 0. + * + * Sibling to 989_siphash_run / 985_adler32_run (the hash/crypto cluster; + * 9xx is full so this shares the 989 prefix — the `short` name keys the + * binary, cf the 949_* multi-file precedent). sha256_test.ww carries its + * own `export fn main()` that drives the NIST-vector @test fns and signals + * which case failed via the exit code, so this file is just a thin + * wrapper — no @test scanning, no synthetic main generation. + */ +#include +#include +#include +#include + +static int +runwait(const char *cmd) +{ + int rc = system(cmd); + if (rc == -1) return -1; + if (WIFEXITED(rc)) return WEXITSTATUS(rc); + return 1; +} + +int +main(void) +{ + const char *bin = getenv("BIN"); + if (!bin) bin = "out/bin"; + char absbin[1024]; + if (bin[0] != '/') { + char cwd[1024]; + if (getcwd(cwd, sizeof cwd) == NULL) return 1; + snprintf(absbin, sizeof absbin, "%s/%s", cwd, bin); + bin = absbin; + } + char cwd[1024]; + if (getcwd(cwd, sizeof cwd) == NULL) return 1; + + const char *src = "lib/crypto/sha256/sha256_test.ww"; + char path[1024], cmd[2048]; + snprintf(path, sizeof path, "%s/%s", cwd, src); + snprintf(cmd, sizeof cmd, "%s/ww run %s", bin, path); + int rc = runwait(cmd); + if (rc != 0) { + fprintf(stderr, "sha256_run FAIL: %s exited %d\n", src, rc); + return 1; + } + printf("sha256_run: %s ok\n", src); + return 0; +}