From e8a119e551e2fb0b6b5d92c60bfec80b22e965a1 Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Wed, 26 Aug 2026 01:15:33 -0700 Subject: [PATCH] Fix buffer lengths and loop iterators. --- AGENTS.md | 59 +++++++++++++++++++++++++++++++++++++++++-- src/assembly/index.ts | 9 +++---- src/lib/verify.ts | 8 +++++- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 056f8bc..6dc9825 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,19 @@ verification for the Nano cryptocurrency. The cryptography is AssemblyScript compiled to WebAssembly; a thin TypeScript layer marshals data across the boundary. Everything is synchronous and single-threaded. +```javascript +derive(prv[, out]) // 32-byte private key -> 32-byte public key +sign(msg, prv, pub[, out]) // 32-byte block hash -> 64-byte signature +verify(sig, msg, pub) // -> boolean +verify_blocks(pub, blocks) // { hash, signature }[], up to 256 -> boolean[] +``` + +Inputs are `Uint8Array` or hex strings, and the output type follows the input +type. `derive` and `sign` take an optional preallocated output buffer as a +trailing argument. `verify_blocks` is the Nano account-chain case — many block +hashes against one public key — and is under active construction; check the +living context document before relying on it. + ## Commands ```bash @@ -39,6 +52,24 @@ stale `dist/` will silently test the previous revision. | `test/node.mjs`, `test/vectors.mjs` | Suite and vectors | | `test/index.html` | Browser test and benchmark page | +## The wasm ABI + +Nothing crosses the boundary as arguments. The host writes into fixed static +buffers, calls an export that takes only a length or a count, and reads results +back out of `OUTPUT_BUFFER`. `src/lib/wasm.ts` resolves every pointer and byte +length once at module load and re-exports them. + +| Buffer | Purpose | +| --- | --- | +| `MESSAGE_BUFFER` | 32 KiB. A message for `sign`/`verify`, or packed 96-byte `hash \|\| signature` records for `verify_blocks` | +| `PRV_BUFFER`, `PUB_BUFFER` | 32 bytes each | +| `SIGNATURE_BUFFER` | one signature for single `verify` | +| `OUTPUT_BUFFER` | public key, signature, or one result byte per verified block | + +Exported byte-length globals (`KEY_BYTELENGTH`, `MESSAGE_BUFFER_BYTELENGTH`, …) +are the single source of truth for these sizes — read them rather than +hardcoding, on both sides of the boundary. + ## Build constraints that break normal assumptions `asconfig.json` disables most of the AssemblyScript safety net. Read these @@ -86,6 +117,23 @@ public exports, so a caller can write more bytes than the length it then declares, and a scoped fill leaves the rest behind. A 32 KiB fill costs ~0.1 µs — roughly 0.4% of one signature — so this is never worth optimising away. +**Counts, lengths, and end offsets are not interchangeable, and confusing them +is silent here.** This project has produced the same bug three separate times: a +byte length passed where an element count was wanted, and a length passed where +an end offset was wanted. With bounds checks compiled out on the wasm side and +typed-array writes silently discarded on the JS side, every instance compiled, +ran, and returned plausible answers. When touching either side of the boundary, +name the quantity in the variable (`…_count`, `…_bytes`, `…_end`) and check each +call against it: + +- `Uint8Array.prototype.fill(value, start, end)` — the third argument is an + **end offset**, not a length. `fill(0, PTR, LEN)` is a silent no-op whenever + `PTR >= LEN`. +- `memory.copy(dest, src, n)` — `n` is **bytes**. For an array of one-byte + results, that is the element count; for anything wider it is not. +- 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. + **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 @@ -109,12 +157,19 @@ never show up on the clock; only the point arithmetic does. `node ./test/node.mjs` after a build. Current state is **6168 passing, 1 failing**, and that one failure is expected. +`verify_blocks` has **no vector coverage yet** — a green suite says nothing +about it. Exercise it directly, and at batch sizes past 64 and past 256, where +the interesting boundaries are. + The failing case is `PROBLEM_VECTOR`: a live cemented Nano block from a small-order account (`nano_11a11…`, public key `0100…00`) that network consensus accepted but strict Ed25519 rejects. The test asserts `true` to match the ledger; `verify()` returns `false` to match the spec. Do not resolve this by -weakening the small-order check in strict verification — the intended shape is a -relaxed variant for Nano blocks alongside a strict one for general use. +weakening the small-order check in strict verification. The intended shape is a +relaxed variant for Nano blocks alongside a strict one for general use; +`crypto_verify_relaxed` and `crypto_verify_strict` both exist, but the relaxed +one is still byte-identical to the strict one, so the split does not yet buy +anything. ## Style diff --git a/src/assembly/index.ts b/src/assembly/index.ts index 27aff69..ce0b288 100644 --- a/src/assembly/index.ts +++ b/src/assembly/index.ts @@ -13,7 +13,7 @@ export const MESSAGE_BUFFER_BYTELENGTH: i32 = BLOCKHASH_BYTELENGTH << 10 export const PRV_BUFFER_BYTELENGTH: i32 = KEY_BYTELENGTH export const PUB_BUFFER_BYTELENGTH: i32 = KEY_BYTELENGTH export const OUTPUT_BUFFER_BYTELENGTH: i32 = 1 << 10 -export const SIGNATURE_BUFFER_BYTELENGTH: i32 = SIGNATURE_BYTELENGTH << 10 +export const SIGNATURE_BUFFER_BYTELENGTH: i32 = SIGNATURE_BYTELENGTH // Static I/O buffers const MESSAGE_BUFFER = new StaticArray(MESSAGE_BUFFER_BYTELENGTH) @@ -27,7 +27,7 @@ export function getMessagePointer (): usize { return changetype(MESSAGE_BUFFER) } -/** Returns the pointer to the static output buffer (512 bytes). */ +/** Returns the pointer to the static output buffer (256 bytes). */ export function getOutputPointer (): usize { return changetype(OUTPUT_BUFFER) } @@ -160,9 +160,6 @@ export function verify (mlen: i32): void { // Clear output buffer of prior data, then copy local result to output buffer OUTPUT_BUFFER.fill(0) OUTPUT_BUFFER[0] = u8(verified) - - // Clear local result - sign_sig.fill(0) } @@ -216,5 +213,5 @@ export function verify_blocks (count: i32): void { // Clear output buffer of prior data, then copy local result to output buffer OUTPUT_BUFFER.fill(0) - memory.copy(changetype(OUTPUT_BUFFER), changetype(verify_blocks_out), count * 32) + memory.copy(changetype(OUTPUT_BUFFER), changetype(verify_blocks_out), count) } diff --git a/src/lib/verify.ts b/src/lib/verify.ts index a0d53c9..fb51cf9 100644 --- a/src/lib/verify.ts +++ b/src/lib/verify.ts @@ -30,6 +30,12 @@ export function verify (sig: unknown, msg: unknown, pub: unknown): boolean { export function verify_blocks (pub: unknown, data: unknown): boolean[] { const blocks: unknown[] = Array.isArray(data) ? data : [data] const count = blocks.length + if (count < 1) { + throw new RangeError('Must pass at least one block', { cause: data }) + } + if (256 < count) { + throw new RangeError('Bulk Nano block verification must be no more than 256 blocks', { cause: data }) + } const verified = new Uint8Array(count) let buffer = new Uint8Array(exports.memory.buffer) try { @@ -54,7 +60,7 @@ export function verify_blocks (pub: unknown, data: unknown): boolean[] { p += SIG_LEN } exports.verify_blocks(blocks.length) - for (let i = 0; i < SIG_LEN; i++) { + for (let i = 0; i < count; i++) { verified[i] = buffer[OUT_PTR + i] } return [...verified].map(v => v === 0) -- 2.52.0