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]
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 = [
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`)