]> git.codecow.com Git - nano25519.git/commitdiff
Implement primordials to capture built-ins before they can be patched or overridden...
authorChris Duncan <chris@zoso.dev>
Fri, 18 Sep 2026 09:57:52 +0000 (02:57 -0700)
committerChris Duncan <chris@zoso.dev>
Fri, 18 Sep 2026 09:57:52 +0000 (02:57 -0700)
12 files changed:
esbuild/config.mjs
package.json
src/index.ts
src/lib/derive.ts
src/lib/index.ts
src/lib/primordials.ts [new file with mode: 0644]
src/lib/sign.ts
src/lib/verify.ts
src/lib/wasm.ts
test/index.html
test/node.mjs
tsconfig.json

index 7b32aa05e814d30a2de0f3bdc271fcc6bb5ba16e..e5e5f321bb075a4860a89bc4d82bd76b88061cd5 100644 (file)
@@ -52,7 +52,6 @@ export const nodeOptions = {
        target: 'node22',
        entryPoints: [
                'src/*',
-               'types/src/*',
        ],
        entryNames: '[name]',
 }
index f1374f51471cb40d37607a10925f03f18175af32..6119d47e25b25e020b4150c41e52f0046b3387b9 100644 (file)
@@ -33,7 +33,7 @@
        "scripts": {
                "build": "npm run clean && npm run compile && node ./esbuild/dev.mjs",
                "build:prod": "npm run clean && npm run compile && node ./esbuild/prod.mjs",
-               "clean": "rm -rf {build,dist,types}",
+               "clean": "rm -rf {build,dist}",
                "compile": "asc ./src/assembly/index.ts && tsc",
                "prepublishOnly": "npm run test:prod",
                "test": "npm run build && node ./test/node.mjs",
index 2e90dd3421d795998715e3f5a4c1d40cbaed7527..ae2e47203e3d1368c7553d55852dbaf9bc6cbf88 100644 (file)
@@ -3,11 +3,9 @@
 
 import * as nano25519 from './lib'
 
-export { constants } from './lib'
+type Bytes = nano25519.Bytes
 
-declare global {
-       type Bytes = Uint8Array<ArrayBuffer>
-}
+export const constants = nano25519.constants
 
 /**
  * Nano public key derivation using WebAssembly. If byte output is required,
index 115a0af7496d53af4e79a5a2a2d5129bc28be21b..b47a79a0b3a3f8a0330fa1ed0621a93584ea06f8 100644 (file)
@@ -1,42 +1,37 @@
 //! SPDX-FileCopyrightText: 2026 Chris Duncan <chris@codecow.com>
 //! SPDX-License-Identifier: GPL-3.0-or-later
 
-import { Mutex, Nano25519TypeError, Pointers, clearMemory, constants, exports, isBytes, memory, normalize } from './wasm'
+import { allocate, byteLength, bytesToHex, copy, fill, isBytes, memoryBuffer, normalize, validateMemoryBuffer, view, } from './primordials'
+import { Mutex, Pointers, clearMemory, constants, exports, memory, } from './wasm'
 
 const { KEY_BYTELENGTH, } = constants
 
 export function derive (prv: unknown, out?: unknown): string | void {
-       if (typeof out !== 'undefined' && !(isBytes(out) && out.byteLength === KEY_BYTELENGTH)) {
+       if (typeof out !== 'undefined' && !(isBytes(out) && byteLength(out) === KEY_BYTELENGTH)) {
                throw new TypeError('Derive output buffer must be 32-byte Uint8Array<ArrayBuffer>')
        }
        const privateKey = normalize('private key', KEY_BYTELENGTH, KEY_BYTELENGTH, prv)
-       const publicKey = new Uint8Array(KEY_BYTELENGTH)
-       let buffer = new Uint8Array(memory.buffer)
+       const publicKey = allocate(KEY_BYTELENGTH)
+       let buffer = view(memoryBuffer(memory))
        Mutex.lock()
        try {
                for (let i = 0; i < KEY_BYTELENGTH; i++) {
                        buffer[Pointers.INPUT_PRV + i] = privateKey[i]
                }
                exports.derive()
-               if (memory.buffer !== buffer.buffer) {
-                       throw new Nano25519TypeError('WASM memory buffer detached')
-               }
+               validateMemoryBuffer(memory, buffer)
                for (let i = 0; i < KEY_BYTELENGTH; i++) {
                        publicKey[i] = buffer[Pointers.OUTPUT_DERIVE + i]
                }
                if (out != null) {
-                       out.set(publicKey)
+                       copy(out, publicKey)
                        return void 0
                } else {
-                       let hex = ''
-                       for (const byte of publicKey) {
-                               hex += byte.toString(16).padStart(2, '0')
-                       }
-                       return hex
+                       return bytesToHex(publicKey)
                }
        } finally {
-               privateKey.fill(0)
-               publicKey.fill(0)
+               fill(privateKey, 0)
+               fill(publicKey, 0)
                clearMemory()
        }
 }
index 9e34dabcddb6787d8e5c09ddec8a393fbcde6b27..0d07aac7814d51e9f3926fb5f36cc08f6e153555 100644 (file)
@@ -2,7 +2,7 @@
 //! SPDX-License-Identifier: GPL-3.0-or-later
 
 export { derive } from './derive'
+export { Bytes } from './primordials'
 export { sign } from './sign'
 export { verify, verify_blocks } from './verify'
 export { constants } from './wasm'
-
diff --git a/src/lib/primordials.ts b/src/lib/primordials.ts
new file mode 100644 (file)
index 0000000..64e4e3a
--- /dev/null
@@ -0,0 +1,250 @@
+//! SPDX-FileCopyrightText: 2026 Chris Duncan <chris@codecow.com>
+//! SPDX-License-Identifier: GPL-3.0-or-later
+
+/**
+ * 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
+ * have replaced in the meantime.
+ *
+ * Two distinct protections are at work here and they need different captures:
+ *
+ * - Binding a constructor (`Bytes`) defends against later reassignment of the
+ *   global, because call sites read this binding instead of looking up
+ *   `Uint8Array` afresh on every call.
+ * - Binding a method or accessor defends against later patching of the
+ *   prototype, because the function reference is frozen rather than resolved
+ *   through the object at call time.
+ *
+ * Neither defends against code that runs *before* this module is evaluated.
+ * That is out of scope: a same-realm attacker who loads first can replace the
+ * package's exports outright, and no copy strategy inside the library changes
+ * that. What this does close is the caller-controlled surface — a hostile
+ * `Uint8Array` subclass reaching input validation through `Symbol.species`, an
+ * overridden `length`, or a spoofed `byteLength`.
+ *
+ * Accessors taken from `%TypedArray%.prototype` read internal slots, so a
+ * subclass cannot lie to them. Prefer them to the equivalent property access
+ * everywhere in this package.
+ *
+ * Only capture what is used *after* this module finishes evaluating. Anything
+ * used solely to build the tables below runs while the realm is still as
+ * trustworthy as it will ever be, so binding it first buys nothing — and
+ * pretending otherwise makes this file harder to audit, not safer. That is why
+ * `Object.getOwnPropertyDescriptor`, `Object.getPrototypeOf` and
+ * `Object.create` appear here as plain globals.
+ *
+ * This module must stay free of imports. It is the trust root, so it has to
+ * finish evaluating before anything that uses it: give it a dependency and the
+ * bundler will order that dependency first, and any call it makes into here
+ * during its own evaluation will read captures that are still unassigned.
+ */
+
+export type Bytes = Uint8Array<ArrayBuffer>
+
+/** Declared here to avoid import loops. */
+export class Nano25519TypeError extends TypeError { }
+
+const isArray = Array.isArray
+
+/** `TypedArray` constructors, bound before anything can replace the global. */
+const Bytes = Uint8Array
+const Words = Uint16Array
+const Longs = Uint32Array
+
+const TypedArray = Object.getPrototypeOf(Bytes.prototype)
+
+const bytesSet = Bytes.prototype.set
+const bytesFill = Bytes.prototype.fill
+
+function get<T> (target: object, key: PropertyKey): (this: unknown) => T {
+       const found = Object.getOwnPropertyDescriptor(target, key)
+       if (typeof found?.get !== 'function') {
+               throw new TypeError(`Missing expected accessor while capturing builtins`)
+       }
+       return found.get
+}
+
+/** Reads `[[TypedArrayName]]`; returns `undefined` for anything else. */
+const getTag = get<string>(TypedArray, Symbol.toStringTag)
+/** Reads internal slot `[[ViewedArrayBuffer]]`. */
+const getBuffer = get<ArrayBufferLike>(TypedArray, 'buffer')
+/** Throws if passed `SharedArrayBuffer`, distinguishing it from `ArrayBuffer`. */
+const getBufferByteLength = get<number>(ArrayBuffer.prototype, 'byteLength')
+/** Reads internal slot `[[ByteLength]]`. */
+const getByteLength = get<number>(TypedArray, 'byteLength')
+/** Reads the live backing store of a `WebAssembly.Memory`. */
+const getMemoryBuffer = get<ArrayBuffer>(WebAssembly.Memory.prototype, 'buffer')
+
+/** Reads one unsigned 32-bit word from `buffer` at byte `offset`. */
+function readLong (buffer: ArrayBufferLike, offset: number): number {
+       return new Longs(buffer)[offset >>> 2]
+}
+
+const fromCharCode = String.fromCharCode
+/**
+ * Reads a UTF-16 string of `length` code units from `buffer` at `offset`.
+ * Used only by the WASM abort handler. Builds the string one code unit at a
+ * time rather than spreading a subarray, which would run the iterator and
+ * risk a stack overflow on a long message.
+ */
+function utf16 (buffer: ArrayBufferLike, offset: number, length: number): string {
+       const words = new Words(buffer)
+       let string = ''
+       for (let i = 0; i < length; i++) {
+               string += fromCharCode(words[offset + i])
+       }
+       return string
+}
+
+/** True for a genuine `ArrayBuffer`, false for a `SharedArrayBuffer`. */
+function isArrayBuffer (a: unknown): a is ArrayBuffer {
+       try {
+               getBufferByteLength.call(a)
+               return true
+       } catch { }
+       return false
+}
+
+/**
+ * True only for a genuine `Uint8Array` backed by a non-shared `ArrayBuffer`.
+ * Reads internal slot `[[TypedArrayName]]`, so a subclass overriding
+ * `Symbol.toStringTag` cannot pass, and a look-alike object cannot either.
+ */
+function isBytes (a: unknown): a is Bytes {
+       return getTag.call(a) === 'Uint8Array' && isArrayBuffer(getBuffer.call(a))
+}
+
+/**
+ * The live backing buffer of `memory`. Read through the captured accessor
+ * because every host read and write goes through it: a patched getter would
+ * redirect the whole I/O path to a decoy buffer, and the detachment check
+ * cannot notice because both of its operands come from the same getter.
+ */
+function memoryBuffer (memory: WebAssembly.Memory): ArrayBuffer {
+       return getMemoryBuffer.call(memory)
+}
+
+/** Checks if WASM memory buffer was detached. */
+function validateMemoryBuffer (memory: WebAssembly.Memory, bytes: Uint8Array): void {
+       if (getMemoryBuffer.call(memory) !== getBuffer.call(bytes)) {
+               throw new Nano25519TypeError('WASM memory buffer detached')
+       }
+}
+
+/** Captured version of `new Uint8Array(byteLength)`. */
+function allocate (length: number): Bytes {
+       return new Bytes(length)
+}
+
+/** Captured version of `Uint8Array.prototype.byteLength`. */
+function byteLength (bytes: Uint8Array): number {
+       return getByteLength.call(bytes)
+}
+
+/**
+ * A copy of `src` in a fresh buffer. Unlike `slice()` this never consults
+ * `Symbol.species`, so it cannot hand control to a caller-supplied subclass,
+ * and it sizes the copy from an internal slot rather than a spoofable `length`.
+ */
+function copy (src: Uint8Array): Bytes
+/** `target.set(source, offset)` through the captured method. */
+function copy (dst: Uint8Array, src: Uint8Array, offset?: number): void
+function copy (a: Uint8Array, b?: Uint8Array, o?: number): void | Bytes {
+       if (b) {
+               return bytesSet.call(a, b, o)
+       }
+       const t = new Bytes(getByteLength.call(a))
+       bytesSet.call(t, a)
+       return t
+}
+
+/** Captured version of `target.fill(value, start, end)`. */
+function fill (target: Uint8Array, value: number, start?: number, end?: number): void {
+       bytesFill.call(target, value, start, end)
+}
+
+/** Captured version of `new Uint8Array(buffer)`. */
+function view (buffer: ArrayBuffer): Bytes {
+       return new Bytes(buffer)
+}
+
+const LOWER = '0123456789abcdef'
+const UPPER = '0123456789ABCDEF'
+const NIBBLE: Record<string, number> = Object.create(null)
+for (let i = 0; i < 16; i++) {
+       NIBBLE[LOWER[i]] = i
+       NIBBLE[UPPER[i]] = i
+}
+
+/**
+ * Decodes hex string to Bytes. Validates input directly instead of using a
+ * patchable `RegExp.prototype.test()`.
+ */
+function hexToBytes (hex: string): Bytes {
+       const length = hex.length
+       if (length & 1) {
+               throw new TypeError(`Invalid hexadecimal length ${length}`)
+       }
+       const bytes = new Bytes(length >> 1)
+       for (let i = 0, b = 0; i < length; i += 2, b++) {
+               const hi = hex[i]
+               const lo = hex[i + 1]
+               if (NIBBLE[hi] == null) {
+                       throw new TypeError(`Invalid hexadecimal character ${hi}`)
+               }
+               if (NIBBLE[lo] == null) {
+                       throw new TypeError(`Invalid hexadecimal character ${lo}`)
+               }
+               bytes[b] = (NIBBLE[hi] << 4) | NIBBLE[lo]
+       }
+       return bytes
+}
+
+/** Lowercase hexadecimal encoding of `bytes`. */
+function bytesToHex (bytes: Uint8Array): string {
+       const byteLength = getByteLength.call(bytes)
+       let hex = ''
+       for (let i = 0; i < byteLength; i++) {
+               const byte = bytes[i]
+               hex += LOWER[byte >> 4] + LOWER[byte & 15]
+       }
+       return hex
+}
+
+/**
+ * Accepts an unknown input; checks that it is a representation of bytes using a
+ * hex string, ArrayBuffer, or Uint8Array; and validates that the byte length of
+ * the value is within a specified range.
+ *
+ * @param {string} name Name of variable to be validated
+ * @param {number} byteLengthMin Throws if byte length is less than this
+ * @param {number} byteLengthMax Throws if byte length is greater than this
+ * @param {unknown} value User input to validate
+ * @returns User input bytes copied to a new Uint8Array for internal use.
+ */
+function normalize (name: string, byteLengthMin: number, byteLengthMax: number, value: unknown): Bytes {
+       if (typeof value === 'string') {
+               if (value.length < (byteLengthMin << 1) || value.length > (byteLengthMax << 1)) {
+                       throw new TypeError(`Invalid hexadecimal length ${value.length} for ${name}`)
+               }
+               return hexToBytes(value)
+       }
+
+       if (isArrayBuffer(value)) {
+               value = new Bytes(value)
+       }
+       if (!isBytes(value)) {
+               throw new TypeError(`${name} must be Uint8Array or ArrayBuffer`)
+       }
+       const bytes = copy(value)
+       const byteLength = getByteLength.call(bytes)
+       if (byteLength < byteLengthMin) {
+               throw new TypeError(`${name} must be at least ${byteLengthMin} bytes`)
+       }
+       if (byteLength > byteLengthMax) {
+               throw new TypeError(`${name} must be no more than ${byteLengthMax} bytes`)
+       }
+       return bytes
+}
+
+export { allocate, byteLength, bytesToHex, copy, fill, isArray, isBytes, memoryBuffer, normalize, readLong, utf16, validateMemoryBuffer, view }
index b6936b981cf62d30dab0fe9fafcb7adc0cf3e77e..cd12b2a2c12447541e365cffbadb886cf8cd594a 100644 (file)
@@ -1,19 +1,20 @@
 //! SPDX-FileCopyrightText: 2026 Chris Duncan <chris@codecow.com>
 //! SPDX-License-Identifier: GPL-3.0-or-later
 
-import { Mutex, Nano25519TypeError, Pointers, clearMemory, constants, exports, isBytes, memory, normalize } from './wasm'
+import { allocate, byteLength, bytesToHex, copy, fill, isBytes, memoryBuffer, normalize, validateMemoryBuffer, view, } from './primordials'
+import { Mutex, Pointers, clearMemory, constants, exports, memory, } from './wasm'
 
 const { KEY_BYTELENGTH, MAX_MESSAGE_BYTELENGTH, SIGNATURE_BYTELENGTH, } = constants
 
 export function sign (msg: unknown, prv: unknown, pub: unknown, out?: unknown): string | void {
-       if (typeof out !== 'undefined' && !(isBytes(out) && out.byteLength === SIGNATURE_BYTELENGTH)) {
+       if (typeof out !== 'undefined' && !(isBytes(out) && byteLength(out) === SIGNATURE_BYTELENGTH)) {
                throw new TypeError(`Sign output buffer must be ${SIGNATURE_BYTELENGTH}-byte Uint8Array<ArrayBuffer>`)
        }
        const message = normalize('message', 0, MAX_MESSAGE_BYTELENGTH, msg)
        const privateKey = normalize('private key', KEY_BYTELENGTH, KEY_BYTELENGTH, prv)
        const publicKey = normalize('public key', KEY_BYTELENGTH, KEY_BYTELENGTH, pub)
-       const signature = new Uint8Array(SIGNATURE_BYTELENGTH)
-       let buffer = new Uint8Array(memory.buffer)
+       const signature = allocate(SIGNATURE_BYTELENGTH)
+       let buffer = view(memoryBuffer(memory))
        Mutex.lock()
        try {
                for (let i = 0; i < message.byteLength; i++) {
@@ -26,26 +27,20 @@ export function sign (msg: unknown, prv: unknown, pub: unknown, out?: unknown):
                        buffer[Pointers.INPUT_PUB + i] = publicKey[i]
                }
                exports.sign(message.byteLength)
-               if (memory.buffer !== buffer.buffer) {
-                       throw new Nano25519TypeError('WASM memory buffer detached')
-               }
+               validateMemoryBuffer(memory, buffer)
                for (let i = 0; i < SIGNATURE_BYTELENGTH; i++) {
                        signature[i] = buffer[Pointers.OUTPUT_SIGN + i]
                }
                if (out != null) {
-                       out.set(signature)
+                       copy(out, signature)
                        return void 0
                } else {
-                       let hex = ''
-                       for (const byte of signature) {
-                               hex += byte.toString(16).padStart(2, '0')
-                       }
-                       return hex
+                       return bytesToHex(signature)
                }
        } finally {
-               privateKey.fill(0)
-               publicKey.fill(0)
-               signature.fill(0)
+               fill(privateKey, 0)
+               fill(publicKey, 0)
+               fill(signature, 0)
                clearMemory()
        }
 }
index 9d4c088f2b43fcc39d6c23065137df239b0aa4be..8356a00582c47d079ba4aa039f27c48d0dc1b49a 100644 (file)
@@ -1,12 +1,13 @@
 //! SPDX-FileCopyrightText: 2026 Chris Duncan <chris@codecow.com>
 //! SPDX-License-Identifier: GPL-3.0-or-later
 
-import { Mutex, Nano25519TypeError, Pointers, clearMemory, constants, exports, memory, normalize } from './wasm'
+import { isArray, memoryBuffer, normalize, validateMemoryBuffer, view } from './primordials'
+import { Mutex, Pointers, clearMemory, constants, exports, memory, } from './wasm'
 
 const { BLOCKHASH_BYTELENGTH, KEY_BYTELENGTH, MAX_MESSAGE_BYTELENGTH, MAX_VERIFY_BLOCKS, SIGNATURE_BYTELENGTH, } = constants
 
 export function verify (sig: unknown, msg: unknown, pub: unknown): boolean {
-       let buffer = new Uint8Array(memory.buffer)
+       let buffer = view(memoryBuffer(memory))
        Mutex.lock()
        try {
                const message = normalize('message', 0, MAX_MESSAGE_BYTELENGTH, msg)
@@ -22,9 +23,7 @@ export function verify (sig: unknown, msg: unknown, pub: unknown): boolean {
                        buffer[Pointers.INPUT_SIG + i] = signature[i]
                }
                exports.verify(message.byteLength)
-               if (memory.buffer !== buffer.buffer) {
-                       throw new Nano25519TypeError('WASM memory buffer detached')
-               }
+               validateMemoryBuffer(memory, buffer)
                const verified = buffer[Pointers.OUTPUT_VERIFY]
                return verified === 1
        } finally {
@@ -33,7 +32,7 @@ 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 blocks: unknown[] = isArray(data) ? data : [data]
        const count = blocks.length
        if (count < 1) {
                throw new RangeError('Must pass at least one block', { cause: data })
@@ -41,8 +40,8 @@ export function verify_blocks (pub: unknown, data: unknown): boolean[] {
        if (MAX_VERIFY_BLOCKS < count) {
                throw new RangeError(`Bulk Nano block verification must be no more than ${MAX_VERIFY_BLOCKS} blocks`, { cause: count })
        }
-       const verified = new Uint8Array(count)
-       let buffer = new Uint8Array(memory.buffer)
+       const verified: boolean[] = []
+       let buffer = view(memoryBuffer(memory))
        Mutex.lock()
        try {
                const publicKey = normalize('public key', KEY_BYTELENGTH, KEY_BYTELENGTH, pub)
@@ -72,13 +71,11 @@ export function verify_blocks (pub: unknown, data: unknown): boolean[] {
                        p += BLOCKHASH_BYTELENGTH
                }
                exports.verify_blocks(count)
-               if (memory.buffer !== buffer.buffer) {
-                       throw new Nano25519TypeError('WASM memory buffer detached')
-               }
+               validateMemoryBuffer(memory, buffer)
                for (let i = 0; i < count; i++) {
-                       verified[i] = buffer[Pointers.OUTPUT_VERIFY + i]
+                       verified[i] = buffer[Pointers.OUTPUT_VERIFY + i] === 1
                }
-               return [...verified].map(v => v === 1)
+               return verified
        } finally {
                clearMemory()
        }
index 38e6ae100c8f742acd012012626ab5b9f1644813..7eb7a2a26a8ad27adeed86a68ef4689d64a33a82 100644 (file)
@@ -3,6 +3,7 @@
 
 //@ts-expect-error
 import nano25519_wasm from '../../build/nano25519.wasm'
+import { Nano25519TypeError, copy, fill, memoryBuffer, readLong, utf16, view, } from './primordials'
 
 type Exports = {
        exports: {
@@ -26,7 +27,7 @@ type Exports = {
        }
 }
 
-export class Nano25519TypeError extends TypeError { }
+export { Nano25519TypeError } from './primordials'
 
 let locked = false
 export const Mutex = {
@@ -45,29 +46,24 @@ export const Mutex = {
  */
 export const memory = new WebAssembly.Memory({ initial: 4 })
 
-const wasm: Bytes = Uint8Array.from(nano25519_wasm)
+const wasm = copy(nano25519_wasm)
 const module = new WebAssembly.Module(wasm)
 
 export const { exports } = new WebAssembly.Instance(module, {
        env: {
                memory,
                abort: (msg: any, file: any, row: any, col: any): never => {
-                       const getString = (pointer: number): string | null => {
-                               const end = pointer + new Uint32Array(memory.buffer)[pointer - 4 >>> 2] >>> 1
-                               const buf = new Uint16Array(memory.buffer)
-                               let start = pointer >>> 1
-                               let string = ''
-                               while (end - start > 1024) {
-                                       string += String.fromCharCode(...buf.subarray(start, start += 1024))
-                               }
-                               return string + String.fromCharCode(...buf.subarray(start, end))
+                       const str = (pointer: number): string | null => {
+                               const end = pointer + readLong(memoryBuffer(memory), pointer - 4) >>> 1
+                               const start = pointer >>> 1
+                               return utf16(memoryBuffer(memory), start, end - start)
                        }
                        // ~lib/builtins/abort(~lib/string/String | null?, ~lib/string/String | null?, u32?, u32?) => void
                        msg >>>= 0
                        file >>>= 0
                        row >>>= 0
                        col >>>= 0
-                       const message = `Nano25519WasmError: ${getString(msg)}, ${getString(file)}, row ${row}, col ${col}`
+                       const message = `Nano25519WasmError: ${str(msg)}, ${str(file)}, row ${row}, col ${col}`
                        throw new Error(message)
                }
        }
@@ -113,54 +109,13 @@ export class Pointers {
 }
 
 export function clearMemory (): void {
-       const bytes = new Uint8Array(memory.buffer)
-       bytes.fill(0, INPUT_MSG, INPUT_MSG + MAX_MESSAGE_BYTELENGTH)
-       bytes.fill(0, INPUT_PRV, INPUT_PRV + KEY_BYTELENGTH)
-       bytes.fill(0, INPUT_PUB, INPUT_PUB + KEY_BYTELENGTH)
-       bytes.fill(0, INPUT_SIG, INPUT_SIG + SIGNATURE_BYTELENGTH)
-       bytes.fill(0, OUTPUT_DERIVE, OUTPUT_DERIVE + KEY_BYTELENGTH)
-       bytes.fill(0, OUTPUT_SIGN, OUTPUT_SIGN + SIGNATURE_BYTELENGTH)
-       bytes.fill(0, OUTPUT_VERIFY, OUTPUT_VERIFY + MAX_VERIFY_BLOCKS)
+       const bytes = view(memoryBuffer(memory))
+       fill(bytes, 0, INPUT_MSG, INPUT_MSG + MAX_MESSAGE_BYTELENGTH)
+       fill(bytes, 0, INPUT_PRV, INPUT_PRV + KEY_BYTELENGTH)
+       fill(bytes, 0, INPUT_PUB, INPUT_PUB + KEY_BYTELENGTH)
+       fill(bytes, 0, INPUT_SIG, INPUT_SIG + SIGNATURE_BYTELENGTH)
+       fill(bytes, 0, OUTPUT_DERIVE, OUTPUT_DERIVE + KEY_BYTELENGTH)
+       fill(bytes, 0, OUTPUT_SIGN, OUTPUT_SIGN + SIGNATURE_BYTELENGTH)
+       fill(bytes, 0, OUTPUT_VERIFY, OUTPUT_VERIFY + MAX_VERIFY_BLOCKS)
        locked = false
 }
-
-export function isBytes (a: unknown): a is Uint8Array<ArrayBuffer> {
-       return a instanceof Uint8Array && a.buffer instanceof ArrayBuffer
-}
-
-export function normalize (name: string, byteLengthMin: number, byteLengthMax: number, value: unknown): Uint8Array<ArrayBuffer> {
-       if (typeof name !== 'string') {
-               throw new TypeError(`Invalid name ${name}`)
-       }
-       if (typeof byteLengthMin !== 'number') {
-               throw new TypeError(`Invalid minimum byte length for ${name}`)
-       }
-       if (typeof byteLengthMax !== 'number') {
-               throw new TypeError(`Invalid maximum byte length for ${name}`)
-       }
-
-       if (typeof value === 'string') {
-               if (/[^0-9a-f]/i.test(value)) {
-                       throw new TypeError(`Invalid hexadecimal characters in ${name}`)
-               }
-               if (value.length & 1 || value.length < (byteLengthMin << 1) || value.length > (byteLengthMax << 1)) {
-                       throw new TypeError(`Invalid hexadecimal length ${value.length} for ${name}`)
-               }
-               return new Uint8Array(value.match(/[0-9a-f]{2}/gi)?.map(b => parseInt(b, 16)) || [])
-       }
-
-       if (value instanceof ArrayBuffer) {
-               value = new Uint8Array(value)
-       }
-       if (!(value instanceof Uint8Array)) {
-               throw new TypeError(`${name} must be Uint8Array or ArrayBuffer`)
-       }
-       const bytes = value.slice()
-       if (bytes.byteLength < byteLengthMin) {
-               throw new TypeError(`${name} must be at least ${byteLengthMin} bytes`)
-       }
-       if (bytes.byteLength > byteLengthMax) {
-               throw new TypeError(`${name} must be no more than ${byteLengthMax} bytes`)
-       }
-       return bytes
-}
index 2f6743d086ece4c61beebc37d565b9c0ce38499d..58bbede76594236ae8d301d30dd9e6943b108a05 100644 (file)
@@ -419,8 +419,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
                                                        start = performance.now()
                                                        const verified = nano25519.verify_blocks(publicKey, blocks)
                                                        end = performance.now()
-                                                       if (verified.some(v => v !== true)) {
-                                                               throw new Error(`invalid result\nsignature: ${signature}\nblock hash: ${blockHash}\npublic key: ${publicKey}`)
+                                                       for (let i = 0; i < verified.length; i++) {
+                                                               if (verified[i] !== true) {
+                                                                       throw new Error(`invalid result ${verified[i]}\nsignature: ${blocks[i].signature}\nblock hash: ${blocks[i].hash}\npublic key: ${publicKey}`)
+                                                               }
                                                        }
                                                        duration = (end - start) / nano25519.constants.MAX_VERIFY_BLOCKS
                                                        deriveTimes[i].push(0)
index db3d7d82baaae4049bb339dcb5546432966a21c4..802eb119dbe25a9d728661998141687a60b1ee4d 100644 (file)
@@ -39,7 +39,7 @@ try {
        result = false
 } catch (err) {
        if (err != null && typeof err === 'object' && 'message' in err) {
-               result = err.message === 'Invalid hexadecimal characters in private key'
+               result = err.message === 'Invalid hexadecimal character z'
        } else {
                result = false
        }
@@ -54,7 +54,7 @@ try {
        result = false
 } catch (err) {
        if (err != null && typeof err === 'object' && 'message' in err) {
-               result = err.message === 'Invalid hexadecimal length 65 for message'
+               result = err.message === 'Invalid hexadecimal length 65'
        } else {
                result = false
        }
@@ -68,7 +68,6 @@ try {
        result = sign('1'.repeat((constants.MAX_MESSAGE_BYTELENGTH << 1) + 1), NANO_ORG_VECTOR.privateKey, NANO_ORG_VECTOR.publicKey)
        result = false
 } catch (err) {
-       console.log(err)
        if (err != null && typeof err === 'object' && 'message' in err) {
                result = err.message === `Invalid hexadecimal length ${(constants.MAX_MESSAGE_BYTELENGTH << 1) + 1} for message`
        } else {
@@ -82,7 +81,7 @@ failures += +!test
 
 // Check byte input validation
 try {
-       result = derive(NANO_ORG_VECTOR.privateKeyBytes.slice(-1))
+       result = derive(NANO_ORG_VECTOR.privateKeyBytes.slice(0, -1))
        result = false
 } catch (err) {
        if (err != null && typeof err === 'object' && 'message' in err) {
@@ -149,7 +148,6 @@ try {
        result = sign(new Uint8Array(constants.MAX_MESSAGE_BYTELENGTH + 1).fill(1), NANO_ORG_VECTOR.privateKeyBytes, NANO_ORG_VECTOR.publicKeyBytes)
        result = false
 } catch (err) {
-       console.log(err)
        if (err != null && typeof err === 'object' && 'message' in err) {
                result = err.message === `message must be no more than ${constants.MAX_MESSAGE_BYTELENGTH} bytes`
        } else {
@@ -511,6 +509,150 @@ check(`verify_blocks missing signature`, test)
 passes += +test
 failures += +!test
 
+// Shared memory is rejected on both inputs and output buffers: isBytes()
+// requires a non-shared ArrayBuffer, so a view over a SharedArrayBuffer never
+// gets through. A bare SharedArrayBuffer stays rejected too (covered above).
+try {
+       const shared = new Uint8Array(new SharedArrayBuffer(constants.KEY_BYTELENGTH))
+       shared.set(NANO_ORG_VECTOR.privateKeyBytes)
+       //@ts-expect-error
+       derive(shared)
+       result = false
+} catch (err) {
+       result = true
+}
+test = result === true
+check(`private key bytes backed by SharedArrayBuffer`, test)
+passes += +test
+failures += +!test
+
+// An output buffer that lies about its size must be caught by this library's own
+// validation, not by a RangeError escaping from the copy. isBytes() reads
+// internal slots, but the byteLength compared against it is a plain property
+// read, which a subclass can override.
+try {
+       class SpoofedOutput extends Uint8Array {
+               get byteLength () { return 32 }
+       }
+       result = derive(NANO_ORG_VECTOR.privateKeyBytes, new SpoofedOutput(4))
+       result = false
+} catch (err) {
+       if (err != null && typeof err === 'object' && 'message' in err) {
+               result = err.message === 'Derive output buffer must be 32-byte Uint8Array<ArrayBuffer>'
+       } else {
+               result = false
+       }
+}
+test = result === true
+check(`derive output buffer lying about byteLength`, test)
+passes += +test
+failures += +!test
+
+// Hardening: a hostile Uint8Array subclass must not reach input handling.
+// It lies through Symbol.species, length, byteLength and Symbol.toStringTag;
+// none of those may be consulted, and none of its code may run inside a call.
+let speciesRan = 0
+class HostileBytes extends Uint8Array {
+       static get [Symbol.species] () {
+               speciesRan++
+               //@ts-expect-error
+               return function (...args) { return new Uint8Array(...args) }
+       }
+       get length () { return 4 }
+       get byteLength () { return 4 }
+       //@ts-expect-error
+       get [Symbol.toStringTag] () { return 'Err' }
+}
+speciesRan = 0
+//@ts-expect-error
+result = derive(new HostileBytes(NANO_ORG_VECTOR.privateKeyBytes))
+test = result === NANO_ORG_VECTOR.publicKey.toLowerCase() && speciesRan === 0
+check(`derive ignores a hostile Uint8Array subclass`, test)
+passes += +test
+failures += +!test
+
+// Hardening: builtins patched after the module loaded must not change results.
+const truth = derive(NANO_ORG_VECTOR.privateKeyBytes)
+const TypedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype)
+/** @type {[string, (() => () => void)][]} */
+const patches = [
+       ['Uint8Array.prototype.slice', () => {
+               const real = Uint8Array.prototype.slice
+               Uint8Array.prototype.slice = () => { throw new Error('patched') }
+               return () => { Uint8Array.prototype.slice = real }
+       }],
+       ['Uint8Array.prototype.set', () => {
+               const real = Uint8Array.prototype.set
+               Uint8Array.prototype.set = () => { throw new Error('patched') }
+               return () => { Uint8Array.prototype.set = real }
+       }],
+       ['Uint8Array.prototype.fill', () => {
+               const real = Uint8Array.prototype.fill
+               Uint8Array.prototype.fill = () => { throw new Error('patched') }
+               return () => { Uint8Array.prototype.fill = real }
+       }],
+       ['String.prototype.padStart', () => {
+               const real = String.prototype.padStart
+               String.prototype.padStart = () => 'XX'
+               return () => { String.prototype.padStart = real }
+       }],
+       ['Number.prototype.toString', () => {
+               const real = Number.prototype.toString
+               Number.prototype.toString = () => 'zz'
+               return () => { Number.prototype.toString = real }
+       }],
+       ['RegExp.prototype.test', () => {
+               const real = RegExp.prototype.test
+               RegExp.prototype.test = () => true
+               return () => { RegExp.prototype.test = real }
+       }],
+       ['Array.isArray', () => {
+               const real = Array.isArray
+               //@ts-expect-error
+               Array.isArray = () => false
+               return () => { Array.isArray = real }
+       }],
+       ['globalThis.Uint8Array', () => {
+               const real = globalThis.Uint8Array
+               //@ts-expect-error
+               globalThis.Uint8Array = () => { throw new Error('patched') }
+               return () => { globalThis.Uint8Array = real }
+       }],
+       ['WebAssembly.Memory.prototype.buffer', () => {
+               const real = Object.getOwnPropertyDescriptor(WebAssembly.Memory.prototype, 'buffer')
+               if (!real) throw new TypeError('Failed to backup WebAssembly.Memory.prototype.buffer')
+               const decoy = new ArrayBuffer(262144)
+               Object.defineProperty(WebAssembly.Memory.prototype, 'buffer', { get () { return decoy }, configurable: true })
+               return () => { Object.defineProperty(WebAssembly.Memory.prototype, 'buffer', real) }
+       }],
+       ['%TypedArray%.prototype.length', () => {
+               const real = Object.getOwnPropertyDescriptor(TypedArrayPrototype, 'length')
+               if (!real) throw new TypeError('Failed to backup TypedArrayPrototype.length')
+               Object.defineProperty(TypedArrayPrototype, 'length', { get () { return 4 }, configurable: true })
+               return () => { Object.defineProperty(TypedArrayPrototype, 'length', real) }
+       }],
+       ['%TypedArray%.prototype.byteLength', () => {
+               const real = Object.getOwnPropertyDescriptor(TypedArrayPrototype, 'byteLength')
+               if (!real) throw new TypeError('Failed to backup TypedArrayPrototype.byteLength')
+               Object.defineProperty(TypedArrayPrototype, 'byteLength', { get () { return 4 }, configurable: true })
+               return () => { Object.defineProperty(TypedArrayPrototype, 'byteLength', real) }
+       }]
+]
+for (const [name, apply] of patches) {
+       const undo = apply()
+       try {
+               result = derive(NANO_ORG_VECTOR.privateKeyBytes) === truth
+       } catch (err) {
+               result = false
+       } finally {
+               undo()
+       }
+       test = result === true
+       check(`derive unaffected by patched ${name}`, test)
+       passes += +test
+       failures += +!test
+}
+
 console.log(`${passes} \x1b[32mPASSED\x1b[0m`)
 console.log(`${failures} \x1b[31mFAILED\x1b[0m`)
 
index d0f1ef7f93436c7d2cb4c77788c5a5c1b8d021f4..8a1687f480aba80c6f11cb433e42ee1e0c8dc605 100644 (file)
@@ -10,9 +10,9 @@
                "strict": true,
                "declaration": true,
                "emitDeclarationOnly": true,
-               "rootDir": ".",
-               "outDir": "types",
-               "declarationMap": true,
+               "rootDir": "src",
+               "outDir": "dist",
+               "declarationMap": false,
                "lib": [
                        "DOM",
                        "ESNext"