From: Chris Duncan Date: Sun, 27 Sep 2026 00:17:57 +0000 (-0700) Subject: Update node tests. X-Git-Url: https://git.codecow.com/?a=commitdiff_plain;h=fffdeb308298077f0ab49f8881594292764193f6;p=nano25519.git Update node tests. --- diff --git a/test/node.mjs b/test/node.mjs index 7b48057..519d936 100644 --- a/test/node.mjs +++ b/test/node.mjs @@ -394,6 +394,78 @@ for (const { privateKey, publicKey, message, signature } of PYTHON_ED25519_BLAKE failures += +!test } +// Every message byte must reach the module. The vectors above stop at 1023 +// bytes, so past that point nothing asserts that bytes are hashed at all, and a +// marshalling bug that drops them is invisible to a round trip: sign and verify +// drop the same bytes, so the signature still verifies. Flip one bit in every +// 32-byte chunk of a maximum-length message and require every flip to be +// rejected. The offset walks 0-31 within the chunk and the bit walks 0-7 every +// 32 chunks, so every (offset, bit) pair is hit 8 times. Then flip every byte +// of the last whole chunk and the 31-byte tail of a message one byte shorter, +// which crosses through the tail path at the top of the buffer. +/** + * @param {number} length Message byte length + * @param {(length: number) => [number, number][]} flips Byte offset and bit mask pairs to try + * @returns {{ baseline: boolean, missed: { byte: number, mask: string, result: unknown }[] }} + */ +function sweepMessage (length, flips) { + const message = new Uint8Array(length).map((_, i) => (i * 37 + 11) & 255) + const missed = [] + let signature + try { + signature = sign(message, NANO_ORG_VECTOR.privateKeyBytes, NANO_ORG_VECTOR.publicKeyBytes) + if (verify(signature, message, NANO_ORG_VECTOR.publicKeyBytes) !== true) { + return { baseline: false, missed } + } + } catch (err) { + return { baseline: false, missed } + } + for (const [i, mask] of flips(length)) { + message[i] ^= mask + try { + result = verify(signature, message, NANO_ORG_VECTOR.publicKeyBytes) + } catch (err) { + result = `threw ${err?.message}` + } + message[i] ^= mask + if (result !== false) { + missed.push({ byte: i, mask: `0x${mask.toString(16).padStart(2, '0')}`, result }) + } + } + return { baseline: true, missed } +} + +const sweeps = [ + [constants.MAX_MESSAGE_BYTELENGTH, (length) => { + const flips = [] + for (let chunk = 0; chunk < length >> 5; chunk++) { + flips.push([(chunk << 5) + (chunk & 31), 1 << ((chunk >> 5) & 7)]) + } + return flips + }], + [constants.MAX_MESSAGE_BYTELENGTH - 1, (length) => { + const flips = [] + for (let i = (length & ~31) - 32; i < length; i++) { + flips.push([i, 1 << (i & 7)]) + } + return flips + }] +] +for (const [length, flips] of sweeps) { + const { baseline, missed } = sweepMessage(length, flips) + + // the sweep only asserts rejections, so assert the baseline too or it dies quietly + test = baseline + check(`sign and verify unmodified ${length}-byte message`, test) + passes += +test + failures += +!test + + test = baseline && missed.length === 0 + check(`verify rejects every flipped byte of ${length}-byte message${missed.length === 0 ? '' : ` (${missed.length} not rejected, first: ${JSON.stringify(missed.slice(0, 4))})`}`, test) + passes += +test + failures += +!test +} + // Check bulk verification of Nano blocks try { result = verify_blocks(NANO_ORG_VECTOR.publicKeyBytes, [{ hash: NANO_ORG_VECTOR.blockHashBytes, signature: NANO_ORG_VECTOR.signatureBytes }])[0] @@ -572,7 +644,15 @@ passes += +test failures += +!test // Hardening: builtins patched after the module loaded must not change results. +// Every primitive runs under every patch. derive never touches the message +// buffer, so a derive-only loop stayed green while sign and verify were signing +// whatever a patched %TypedArray%.prototype.buffer or DataView getUint32 handed +// them. The message is 65 bytes so it crosses as two whole chunks plus a +// one-byte tail, exercising both marshalling paths. const truth = derive(NANO_ORG_VECTOR.privateKeyBytes) +const patchedMessage = new Uint8Array(65).map((_, i) => (i * 37 + 11) & 255) +const truthSignature = sign(patchedMessage, NANO_ORG_VECTOR.privateKeyBytes, NANO_ORG_VECTOR.publicKeyBytes) +const patchedBlocks = [{ hash: NANO_ORG_VECTOR.blockHashBytes, signature: NANO_ORG_VECTOR.signatureBytes }] const TypedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype) /** @type {[string, (() => () => void)][]} */ const patches = [ @@ -636,21 +716,69 @@ const patches = [ 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) } + }], + ['%TypedArray%.prototype.byteOffset', () => { + const real = Object.getOwnPropertyDescriptor(TypedArrayPrototype, 'byteOffset') + if (!real) throw new TypeError('Failed to backup TypedArrayPrototype.byteOffset') + Object.defineProperty(TypedArrayPrototype, 'byteOffset', { get () { return 4 }, configurable: true }) + return () => { Object.defineProperty(TypedArrayPrototype, 'byteOffset', real) } + }], + ['%TypedArray%.prototype.buffer', () => { + const real = Object.getOwnPropertyDescriptor(TypedArrayPrototype, 'buffer') + if (!real?.get) throw new TypeError('Failed to backup TypedArrayPrototype.buffer') + const getReal = real.get + // Same size as the real buffer, so a view built over it succeeds and the + // module silently signs these bytes instead of throwing. + Object.defineProperty(TypedArrayPrototype, 'buffer', { + get () { + const decoy = new ArrayBuffer(getReal.call(this).byteLength) + new Uint8Array(decoy).fill(0x41) + return decoy + }, + configurable: true + }) + return () => { Object.defineProperty(TypedArrayPrototype, 'buffer', real) } + }], + ['DataView.prototype.getUint32', () => { + const real = DataView.prototype.getUint32 + DataView.prototype.getUint32 = () => 0x41414141 + return () => { DataView.prototype.getUint32 = real } + }], + ['globalThis.DataView', () => { + const real = globalThis.DataView + //@ts-expect-error + globalThis.DataView = () => { throw new Error('patched') } + return () => { globalThis.DataView = real } + }], + ['globalThis.Uint32Array', () => { + const real = globalThis.Uint32Array + //@ts-expect-error + globalThis.Uint32Array = () => { throw new Error('patched') } + return () => { globalThis.Uint32Array = real } }] ] +/** @type {[string, () => boolean][]} */ +const patchedPrimitives = [ + ['derive', () => derive(NANO_ORG_VECTOR.privateKeyBytes) === truth], + ['sign', () => sign(patchedMessage, NANO_ORG_VECTOR.privateKeyBytes, NANO_ORG_VECTOR.publicKeyBytes) === truthSignature], + ['verify', () => verify(truthSignature, patchedMessage, NANO_ORG_VECTOR.publicKeyBytes) === true], + ['verify_blocks', () => verify_blocks(NANO_ORG_VECTOR.publicKeyBytes, patchedBlocks)[0] === true] +] for (const [name, apply] of patches) { - const undo = apply() - try { - result = derive(NANO_ORG_VECTOR.privateKeyBytes) === truth - } catch (err) { - result = false - } finally { - undo() + for (const [primitive, run] of patchedPrimitives) { + const undo = apply() + try { + result = run() + } catch (err) { + result = false + } finally { + undo() + } + test = result === true + check(`${primitive} unaffected by patched ${name}`, test) + passes += +test + failures += +!test } - test = result === true - check(`derive unaffected by patched ${name}`, test) - passes += +test - failures += +!test } console.log(`${passes} \x1b[32mPASSED\x1b[0m`)