| `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
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
- 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
## 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
"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.