]> git.codecow.com Git - nano25519.git/commitdiff
Update agent file.
authorChris Duncan <chris@codecow.com>
Fri, 4 Sep 2026 22:09:02 +0000 (15:09 -0700)
committerChris Duncan <chris@codecow.com>
Fri, 4 Sep 2026 22:09:02 +0000 (15:09 -0700)
AGENTS.md

index 4abfaaeed1975d69026fb6a5c29e226c3b5ead1d..cd2ecd5e335860c7b374e42840ed16a2e0808294 100644 (file)
--- 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.