From c8e1ff0ea56d5109948e74430535b5fa8a36c627 Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Sat, 26 Sep 2026 02:12:27 -0700 Subject: [PATCH] Abandon conditional word copy function selection in favor of reliability. --- src/lib/primordials.ts | 21 ++++--------- src/lib/wasm.ts | 68 ++++++++---------------------------------- 2 files changed, 18 insertions(+), 71 deletions(-) diff --git a/src/lib/primordials.ts b/src/lib/primordials.ts index 4849056..5af4e94 100644 --- a/src/lib/primordials.ts +++ b/src/lib/primordials.ts @@ -1,8 +1,6 @@ //! SPDX-FileCopyrightText: 2026 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later -import { Nano25519RangeError, Nano25519TypeError } from "./errors" - /** * Builtins captured once, when this module is evaluated, so that nothing on a * call path resolves a global or a prototype method that hostile code could @@ -43,14 +41,11 @@ import { Nano25519RangeError, Nano25519TypeError } from "./errors" /** Type alias for legibility. */ export type Bytes = Uint8Array -export type Words = Uint32Array export type View = DataView export const isArray = Array.isArray -export const isLE = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1 /** `TypedArray` constructors, bound before anything can replace the globals. */ const Bytes = Uint8Array -const Words = Uint32Array const TypedArray = Object.getPrototypeOf(Bytes.prototype) const View = DataView @@ -126,28 +121,24 @@ export function isBytes (a: unknown): a is Bytes { return getTag.call(a) === 'Uint8Array' && isArrayBuffer(getBuffer.call(a)) } -/** Captured version of `DataView` reusing input offset and length. */ -export function view (bytes: Bytes): View { - return new View(getBuffer.call(bytes) as ArrayBuffer, getByteOffset.call(bytes), getByteLength.call(bytes)) -} - /** - * Captured version of `Uint32Array`. Enables reading multiple bytes in a single + * Captured version of `DataView` reusing input offset and length. + * Enables reading multiple bytes in a single * load which is more efficient than reading and combining individual bytes * with bitshifts. The caller is responsible for managing endianness. * * Every boundary-crossing buffer comes from here or `normalize()`, so the * offset is always 0 in practice; the offset check is a guard against mishap. */ -export function words (bytes: Bytes): Words { +export function view (bytes: Bytes): View { if (!isBytes(bytes)) { - throw new Nano25519TypeError('Error creating Uint32Array', { cause: bytes }) + throw new TypeError('Error creating DataView', { cause: bytes }) } const offset = getByteOffset.call(bytes) if (offset !== 0) { - throw new Nano25519RangeError('Unexpected byte offset', { cause: offset }) + throw new RangeError('Unexpected byte offset', { cause: offset }) } - return new Words(getBuffer.call(bytes) as ArrayBuffer, 0, getByteLength.call(bytes) >> 2) + return new View(getBuffer.call(bytes) as ArrayBuffer, offset, getByteLength.call(bytes)) } const LOWER = '0123456789abcdef' diff --git a/src/lib/wasm.ts b/src/lib/wasm.ts index 0f44b83..54cf3bf 100644 --- a/src/lib/wasm.ts +++ b/src/lib/wasm.ts @@ -119,71 +119,27 @@ function setInputMsgEnd (m: Bytes, i: number): void { * remaining bytes, fewer than 32, go through `setInputMsgBytes`, which handles * the zero padding. */ -function setInputMsgBigEndian (m: View, i: number, p: number): void { - p <<= 2 - exports.setInputMsg(i, - m.getUint32(p, true), - m.getUint32(p + 1, true), - m.getUint32(p + 2, true), - m.getUint32(p + 3, true), - m.getUint32(p + 4, true), - m.getUint32(p + 5, true), - m.getUint32(p + 6, true), - m.getUint32(p + 7, true) - ) -} - -/** - * Copies the bytes of a message for signing, or signature verification, into - * the static message input buffer of the WASM module. The entire input is - * written in one pass rather than looped over an intermediate array because - * allocating costs more than the copy itself. - * - * Whole 32-byte chunks are read a word at a time through a `Uint32Array` view. - * A `Uint32Array` is laid out little-endian on every platform that can run - * WASM, so a word read is already in the order the module's `store` wants - * and needs no shifting — measured over 64 KiB, that takes the loop from ~45 µs - * to ~13 µs, which is the cost of the 2048 boundary crossings alone. The - * remaining bytes, fewer than 32, go through `setInputMsgBytes`, which handles - * the zero padding. - * - * Widening the crossing to 64 bytes per call was measured at ~12 µs, under the - * ~2% noise floor for a whole `sign` or `verify`, so the ABI stays at 32. - */ -function setInputMsgLittleEndian (m: Words, i: number, p: number): void { - exports.setInputMsg(i, - m[p], - m[p + 1], - m[p + 2], - m[p + 3], - m[p + 4], - m[p + 5], - m[p + 6], - m[p + 7] - ) -} - function setInputMsg (message: Bytes): void { const mlen = byteLength(message) const mlentrunc = mlen & ~31 - const m = setInputMsgView(message) - for (let i = 0, p = 0; i < mlentrunc; i += 32, p += 8) { - setInputMsgEndian(m as (Words & View), i, p) + const m = view(message) + for (let i = 0; i < mlentrunc; i += 32) { + exports.setInputMsg(i, + m.getUint32(i, true), + m.getUint32(i + 4, true), + m.getUint32(i + 8, true), + m.getUint32(i + 12, true), + m.getUint32(i + 16, true), + m.getUint32(i + 20, true), + m.getUint32(i + 24, true), + m.getUint32(i + 28, true) + ) } if (mlentrunc < mlen) { setInputMsgEnd(message, mlentrunc) } } -/** - * Selected once, at load, so the hot loop carries no per-chunk branch. The - * word path is wrong on a big-endian platform and there is no such WASM host, - * but the check costs one probe at load and removes the silent-wrong-answer - * failure mode entirely. - */ -const setInputMsgEndian = isLE ? setInputMsgLittleEndian : setInputMsgBigEndian -const setInputMsgView = isLE ? words : view - /** * Copies the bytes of a private key into the static private key input buffer of * the WASM module. Bytes are packed in little-endian order to align with the -- 2.52.0