]> git.codecow.com Git - nano25519.git/commitdiff
Update node tests.
authorChris Duncan <chris@codecow.com>
Sun, 27 Sep 2026 00:17:57 +0000 (17:17 -0700)
committerChris Duncan <chris@codecow.com>
Sun, 27 Sep 2026 00:17:57 +0000 (17:17 -0700)
test/node.mjs

index 7b480579225e0653e642b513366c59f5f4d09f2d..519d936ee42e62bcd0bd869f5a12351631210835 100644 (file)
@@ -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`)