From: Chris Duncan Date: Fri, 18 Sep 2026 09:57:52 +0000 (-0700) Subject: Implement primordials to capture built-ins before they can be patched or overridden... X-Git-Url: https://git.codecow.com/?a=commitdiff_plain;h=529a147f44dacb4a33ac39ea841a46c1c47d1454;p=nano25519.git Implement primordials to capture built-ins before they can be patched or overridden by subsequent malicious code. --- diff --git a/esbuild/config.mjs b/esbuild/config.mjs index 7b32aa0..e5e5f32 100644 --- a/esbuild/config.mjs +++ b/esbuild/config.mjs @@ -52,7 +52,6 @@ export const nodeOptions = { target: 'node22', entryPoints: [ 'src/*', - 'types/src/*', ], entryNames: '[name]', } diff --git a/package.json b/package.json index f1374f5..6119d47 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.ts b/src/index.ts index 2e90dd3..ae2e472 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,11 +3,9 @@ import * as nano25519 from './lib' -export { constants } from './lib' +type Bytes = nano25519.Bytes -declare global { - type Bytes = Uint8Array -} +export const constants = nano25519.constants /** * Nano public key derivation using WebAssembly. If byte output is required, diff --git a/src/lib/derive.ts b/src/lib/derive.ts index 115a0af..b47a79a 100644 --- a/src/lib/derive.ts +++ b/src/lib/derive.ts @@ -1,42 +1,37 @@ //! SPDX-FileCopyrightText: 2026 Chris Duncan //! 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') } 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() } } diff --git a/src/lib/index.ts b/src/lib/index.ts index 9e34dab..0d07aac 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -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 index 0000000..64e4e3a --- /dev/null +++ b/src/lib/primordials.ts @@ -0,0 +1,250 @@ +//! SPDX-FileCopyrightText: 2026 Chris Duncan +//! 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 + +/** 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 (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(TypedArray, Symbol.toStringTag) +/** Reads internal slot `[[ViewedArrayBuffer]]`. */ +const getBuffer = get(TypedArray, 'buffer') +/** Throws if passed `SharedArrayBuffer`, distinguishing it from `ArrayBuffer`. */ +const getBufferByteLength = get(ArrayBuffer.prototype, 'byteLength') +/** Reads internal slot `[[ByteLength]]`. */ +const getByteLength = get(TypedArray, 'byteLength') +/** Reads the live backing store of a `WebAssembly.Memory`. */ +const getMemoryBuffer = get(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 = 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 } diff --git a/src/lib/sign.ts b/src/lib/sign.ts index b6936b9..cd12b2a 100644 --- a/src/lib/sign.ts +++ b/src/lib/sign.ts @@ -1,19 +1,20 @@ //! SPDX-FileCopyrightText: 2026 Chris Duncan //! 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`) } 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() } } diff --git a/src/lib/verify.ts b/src/lib/verify.ts index 9d4c088..8356a00 100644 --- a/src/lib/verify.ts +++ b/src/lib/verify.ts @@ -1,12 +1,13 @@ //! SPDX-FileCopyrightText: 2026 Chris Duncan //! 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() } diff --git a/src/lib/wasm.ts b/src/lib/wasm.ts index 38e6ae1..7eb7a2a 100644 --- a/src/lib/wasm.ts +++ b/src/lib/wasm.ts @@ -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 { - return a instanceof Uint8Array && a.buffer instanceof ArrayBuffer -} - -export function normalize (name: string, byteLengthMin: number, byteLengthMax: number, value: unknown): Uint8Array { - 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 -} diff --git a/test/index.html b/test/index.html index 2f6743d..58bbede 100644 --- a/test/index.html +++ b/test/index.html @@ -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) diff --git a/test/node.mjs b/test/node.mjs index db3d7d8..802eb11 100644 --- a/test/node.mjs +++ b/test/node.mjs @@ -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' + } 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`) diff --git a/tsconfig.json b/tsconfig.json index d0f1ef7..8a1687f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,9 +10,9 @@ "strict": true, "declaration": true, "emitDeclarationOnly": true, - "rootDir": ".", - "outDir": "types", - "declarationMap": true, + "rootDir": "src", + "outDir": "dist", + "declarationMap": false, "lib": [ "DOM", "ESNext"