From 00cef6753b757efd239b03130a2e2f2a96f64745 Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Fri, 4 Sep 2026 15:09:02 -0700 Subject: [PATCH] Update agent file. --- AGENTS.md | 96 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 81 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4abfaae..cd2ecd5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,12 @@ stale `dist/` will silently test the previous revision. | `src/assembly/crypto_{derive,sign,verify}.ts` | RFC 8032-shaped primitives over BLAKE2b | | `src/assembly/{fe,ge,p,sc}.ts` | Field element, group element, point, and scalar arithmetic | | `src/assembly/{base,base2}.ts` | Precomputed base point tables as static data segments | -| `test/node.mjs`, `test/vectors.mjs` | Suite and vectors | +| `src/assembly/constants.ts` | Byte lengths and caps; imported by everything, imports nothing | +| `src/assembly/errors.ts` | Error text as a plain array, thrown by index | +| `src/assembly/utils.ts` | `equalbytes` and the other constant-time helpers | +| `src/assembly/tests.ts` | Start-function build guard — see **Testing** | +| `test/node.mjs`, `test/vectors.mjs` | Suite, and the vectors it is built from | +| `test/python_ed25519_blake2b_vectors.mjs` | The bulk vector set, split out to stop crashing the IDE | | `test/index.html` | Browser test and benchmark page | ## The wasm ABI @@ -227,10 +232,16 @@ ordinary AssemblyScript: arithmetic over other constants is fine and still const-folds. - **`enable: ["simd"]`** — field elements are 12 `i32` limbs, not 10. The two trailing limbs are padding; respect the stride. +- **`importMemory: true`** — the **host** creates the `WebAssembly.Memory` and the + module imports it. This is what lets `env.abort` decode a message thrown from + the start function: the handler reads the host's `memory`, which exists before + instantiation, rather than `exports.memory`, which does not. The host's + `initial` is hand-maintained in `src/lib/wasm.ts` but cannot drift silently — + too small is a `LinkError` at instantiation, too large is simply accepted. - **`initialMemory: 4`** — pins the module at 4 pages (262,144 B); heap at rest - is 135,856 B. Only the stub allocator's *growth* doubles (1→2→4→8); the setting + is 137,792 B. Only the stub allocator's *growth* doubles (1→2→4→8); the setting itself takes any integer, so it need not be a power of two, and 3 pages do fit - today with 60,752 B spare. **Slack here is nearly free**: measured, the fourth + today with 58,816 B spare. **Slack here is nearly free**: measured, the fourth page changes neither throughput nor instantiation time and never becomes resident, because nothing writes to it — it costs address space and V8 accounting, not RAM. Re-derive it whenever a buffer is resized rather than @@ -308,6 +319,13 @@ call against it: - A loop bound of `SIG_LEN` or `KEY_LEN` where the intent was "per item" reads the right number of bytes for the wrong reason. +One instance is still open: `crypto_verify.ts:62` passes a literal `32` to +`equalbytes` where the line above it already reads `KEY_BYTELENGTH`. The literal +is correct, so this is cosmetic — but it is a length argument on the one +comparison carrying the batch path's whole security property. It used to be +blocked by an import cycle through `index.ts`; `constants.ts` removed that, so +the fix is now a one-word edit. + **Do not add JS-side suspension points.** The safety of hashing straight out of the shared message buffer rests on there being no `await`, no yield, and no callback into user code between the host writing the buffer and reading the @@ -350,22 +368,47 @@ show up on the clock; only the point arithmetic does. ## Testing -`node ./test/node.mjs` after a build. Current state is **6177 passing, 0 +`node ./test/node.mjs` after a build. Current state is **6178 passing, 0 failing**. There is no longer an expected failure — if anything fails, it is a regression. -`equalbytes` additionally self-tests in the module's **start function** (top-level -code in `utils.ts`), flipping all **256 bit positions** — not one bit per byte, -which is the shape that let the original defect survive its own test. A regression there makes the -module fail to instantiate, so the package becomes unimportable rather than -silently weakening signature verification. Keep that pattern for anything whose -failure mode is "quietly accepts too much". +`equalbytes` additionally self-tests in the module's **start function**, in +`src/assembly/tests.ts`, imported by `index.ts` so it runs at instantiation. A +regression there makes the module fail to instantiate, so the package becomes +unimportable rather than silently weakening signature verification. Keep that +pattern for anything whose failure mode is "quietly accepts too much". + +Three key vectors — all-zero, all-ones, and `spread` (32 distinct bytes from +`i * 37 + 11`; 37 is odd, so it is a bijection mod 256, and the multiplier is +large enough to wrap, which is what balances every bit position across both +values). Boundary vectors expose an accumulator that saturates or sign-extends; +the distinct one exposes anything value-dependent, which a uniform vector cannot. +Each is compared against itself, then corrupted in **two shapes**: + +- **one byte**, at every one of 32 positions, with every one of the 255 non-zero + masks — 8,160 per vector. +- **two bytes**, `0x80` at each, over **all 496 distinct pairs** — the delta that + cancels under both `^=` and `+=`. + +**25,971 comparisons per instantiation**, all but three of which must report +unequal, at a cost of **0.565 ms**. + +⚠️ **The two-byte shape is not optional, and nothing else catches what it +catches.** `b |= d` has no inverse, but `b ^= d` and `b += d` are each one +character away and each cancels on the right pair. Built with `^=`, every +single-byte case still passes. **All 496 pairs, not adjacent pairs**: the +cancelling stride is the accumulator's width, so a 16-byte SIMD accumulator +cancels at stride 16 and never at stride 1 — an adjacent-pair sweep would miss it +entirely. Only the full pairwise sweep covers every stride. + +The guard reports which vector and which bytes: + +```text +equalbytes build error from "zeros": expected fail [0,0,…]; actual pass [128,128,0,…] +``` -⚠️ **An abort during instantiation loses its message.** The `env.abort` handler -reads `exports.memory`, but `exports` is still in its temporal dead zone while -the start function runs, so a fired self-test reports `Cannot access 'memory' -before initialization` rather than its own text. If the module refuses to load -with that error, suspect an init-time assertion, not a binding problem. +That message survives instantiation only because of `importMemory: true` — see +**Build constraints**. `verify_blocks` now has vector coverage — a single block, a full `MAX_VERIFY_BLOCKS`-sized batch, both range errors, and the three block-shape `TypeError`s. Coverage still @@ -382,6 +425,29 @@ reject it, `verify_blocks()` must accept it. Both assertions pass. Do not "simplify" this into one expectation — it is the regression test for the whole strict/permissive split. +`test/node.mjs` also carries an end-to-end **canary** over the public API, which +catches a disjoint class from the start-function guard: a *correct* `equalbytes` +called with a wrong length instantiates cleanly and passes `tests.ts`, because +the defect is at the call site, not in the function. The canary sweeps all 8,160 +single-byte forgeries of `R` and requires `verify_blocks` to reject every one, +reporting a single aggregated assertion. + +It works **only** because `IDENTITY`'s public key is the identity point, which +makes `[k]A` the identity and pins `check_r = [S]B` independently of `R`. With a +normal account `R` is hashed into the challenge, so mutating it moves both sides +of the comparison and a weakened compare stays invisible at 2⁻³². Retargeting the +canary at a normal account leaves a test that passes on a forgeable build. +`IDENTITY.signatureBytes` is `enc(identity) ‖ 32 zero bytes` — with `S = 0`, +`check_r` is the identity, so the baseline verifies for **any** message hash. + +⚠️ **The canary asserts only rejections, so it dies quietly.** Nothing asserts +that the unmodified signature is *accepted*. Any future change that stops that +baseline verifying — an `S ≠ 0` check, a degenerate-signature reject, a +small-order-`A` reject on the permissive path — turns all 8,160 iterations into +trivial rejections and the canary goes green forever while testing nothing. +Verified: with a wrong `A`, the sweep still reports PASS. Assert the baseline +alongside the sweep. + ## Style - Tabs for indentation, no semicolons, single quotes. -- 2.52.0