]> git.codecow.com Git - nano25519.git/commitdiff
Update agent file.
authorChris Duncan <chris@codecow.com>
Sat, 29 Aug 2026 22:11:28 +0000 (15:11 -0700)
committerChris Duncan <chris@codecow.com>
Sat, 29 Aug 2026 22:14:44 +0000 (15:14 -0700)
AGENTS.md

index 185c8a927e861af475c2968f5dadb4078aaa4a21..a014cce2c0fefa55746fa4e9b1f1abe53a3c53b5 100644 (file)
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -43,10 +43,13 @@ They will disagree about real cemented Nano blocks. That is the design, not a
 bug: `verify_blocks` answers *"does the ledger accept this"*, `verify` answers
 *"is this a sound Ed25519 signature"*. Do not "fix" one to match the other.
 
-`crypto_verify_relaxed` **requires** `crypto_verify_decodepubkey` to have run —
-it reuses the module-level `A` that call leaves behind, which is what makes the
-batch hoist possible. Call it cold and you verify against whichever key ran last,
-with no error. `crypto_verify_strict` does its own key handling inline.
+`crypto_verify_decodepubkey` returns **`ge_p3 | null`**, and
+`crypto_verify_relaxed(s, M, A, pub)` takes that `A` as a parameter. Skipping the
+key check is therefore a **compile error**, not a silent wrong answer — it used
+to reuse a module-level `A` left behind by the previous call, so calling it cold
+verified against whichever key ran last. Keep the value threaded through the
+signature rather than reintroducing shared state. `crypto_verify_strict` does its
+own key handling inline.
 
 **The cap is a latency decision, not a throughput one.** `(1 - 1/n)` is 96.9% at
 n=32, **98.4% at n=64**, 99.7% at n=341 — past ~64 you buy tenths of a percent of
@@ -122,7 +125,7 @@ batch, and both roles are now in one expression, so the check is automatic.
 Records in `INPUT_MSG` are **`signature` then `hash`** (64 B + 32 B), libsodium
 combined-mode order. Nothing validates this; both sides just have to agree.
 
-The batch cap is `MAX_VERIFY_BLOCKS` (32); the wasm guard, the host guard and
+The batch cap is `MAX_VERIFY_BLOCKS` (64); the wasm guard, the host guard and
 `OUTPUT_VERIFY` all read it, so they cannot drift.
 
 `MAX_MESSAGE_BYTELENGTH` is a **deliberately chosen policy limit** — a cap on
@@ -158,6 +161,47 @@ cap change needed no edit there. Keep it that way: a stale denominator in the
 benchmark does not error, it silently reports a wrong per-block time in the file
 whose whole job is producing that number.
 
+## Host-side invariants
+
+Three properties the TypeScript layer now enforces rather than assumes. All three
+were conventions that held only while every caller behaved.
+
+**A mutex guards the shared buffers, and `Mutex.lock()` goes *outside* the
+`try`.** Every host entry point locks before it writes, and the `finally` clears
+the buffers and releases. Acquiring inside the `try` is the bug this shape avoids:
+a failed acquisition would run the `finally` and scrub the buffers out from under
+the call that legitimately holds the lock. `lock()` throws
+`Nano25519TypeError('Failed to acquire mutex')` rather than waiting — the module
+is single-threaded and synchronous, so a second acquirer means an async wrapper
+is interleaving calls, which is exactly what must not happen silently.
+
+**A detached buffer is an error, not something to re-wrap.** After a wasm call
+the host asserts `exports.memory.buffer === buffer.buffer` and throws
+`Nano25519TypeError('WASM memory buffer detached')`. Re-wrapping the view after
+the call — the previous shape — papers over memory growth that should never occur
+and hands back results read from a different buffer than the one written.
+
+**`OUTPUT_VERIFY` is fail-closed at every point it could be read.** It is filled
+with `255` at instantiation, again at the top of `verify_blocks` before the count
+check, and again by the host's `clear()`. `0` means verified, so a buffer that was
+never written must never read as zero.
+
+## Wasm error reporting
+
+Error text lives in `src/assembly/errors.ts` as a plain array, and exported
+functions throw by index (`throw new Error(errors[2])`). This lets an inner
+primitive return a code that an outer function turns into a message: `crypto_sign`
+returns `i32` rather than throwing, so `sign()` can scrub `OUTPUT_SIGN` before it
+rethrows — a throw from deep inside would otherwise leave a partial signature in
+an output buffer.
+
+Indexing the array does **not** break the message. That is worth stating because
+the `throw new Error('literal')` note below makes it look like it should: the
+null-pointer failure there is specific to a *hoisted* `Error` object's `.message`
+field, not to a runtime string operand. Verified — a wrong public key reaches the
+host as `Nano25519WasmError: Invalid public key, src/assembly/index.ts, row 143,
+col 3`.
+
 ## Build constraints that break normal assumptions
 
 `asconfig.json` disables most of the AssemblyScript safety net. Read these
@@ -208,10 +252,13 @@ store, and makes AssemblyScript pass a null message pointer, so the error text
 is lost before it reaches the host.
 
 **A comparison function needs a test that makes things unequal.** `equalbytes`
-shipped comparing 12 of 32 bytes — the 12 being the FieldElement limb count,
-copied into a byte count. It reduced a 256-bit signature check to 96 bits, and
-the full suite passed the whole time, because valid signatures pass under the bug
-and random ones fail. Any function whose job is *"return false unless equal"*
+shipped comparing **one bit per byte** — the `>> 8` shift above was a no-op on a
+`u8` accumulator, so only bit 0 of each byte was ever tested. Fixing the loop
+bound (it had been the FieldElement limb count, 12, copied into a byte count)
+took the signature check from 12 bits to **32**, never to 256; the shift was the
+real defect and outlived that fix. A 32-bit check makes `verify_blocks` forgeable
+at ~2³² offline work. The full suite passed the whole time, because valid
+signatures pass under the bug and random ones fail. Any function whose job is *"return false unless equal"*
 needs a test that flips **every** byte position, not one that checks the equal
 case. The same applies to a result buffer: assert the failure path, not just the
 success path.
@@ -303,7 +350,8 @@ 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 32 byte positions. A regression there makes the
+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".
@@ -314,8 +362,8 @@ 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.
 
-`verify_blocks` now has vector coverage — a single block, a full 32-block
-batch, both range errors, and the three block-shape `TypeError`s. Coverage still
+`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
 runs thin in the middle: its bugs have twice been invisible below a boundary
 (64, then 256) and appeared only past it, so exercise new changes across the
 whole range rather than at one size, and always corrupt at least one signature
@@ -329,12 +377,6 @@ 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.
 
-Coverage still runs thin in the middle: `verify_blocks` bugs have twice been
-invisible below a boundary (64, then 256) and appeared only past it, so exercise
-changes across the whole range, and always corrupt at least one signature in the
-batch — the bug this feature has actually shipped is reporting invalid signatures
-as valid, which an all-valid batch cannot catch.
-
 ## Style
 
 - Tabs for indentation, no semicolons, single quotes.