-
Notifications
You must be signed in to change notification settings - Fork 0
fix(webpack): give the browser bundle the node builtins node-rsa needs #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,185 @@ | ||
| // Check that the browser bundle works where a browser runs it. The webpack | ||
| // build proves only that every import resolved; it cannot show whether the | ||
| // node builtins the bundle maps away still behave once they are gone. So this | ||
| // loads the emitted chunks into a context that holds no Buffer, no process, no | ||
| // global and no require, then drives the RSA host function end to end: node-rsa | ||
| // reaches the crypto shim, the shim reaches create-hash, and the buffer package | ||
| // stands in for the node one. | ||
| // | ||
| // Usage: node scripts/browser-bundle.test.js | ||
| // | ||
| // Run yarn run build-web first. This script builds nothing. | ||
| // | ||
| // It reports in the same shape as the bash tests beside it, since CI and a | ||
| // reader both want one format, but the work is a node sandbox rather than shell. | ||
| 'use strict'; | ||
|
|
||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const vm = require('vm'); | ||
| const crypto = require('crypto'); | ||
|
|
||
| const ROOT = path.join(__dirname, '..'); | ||
| const DIST = path.join(ROOT, 'dist-web'); | ||
| const NodeRSA = require(path.join(ROOT, 'node_modules', 'node-rsa')); | ||
|
|
||
| let cases = 0; | ||
| let passed = 0; | ||
|
|
||
| function ok(name) { | ||
| cases += 1; | ||
| passed += 1; | ||
| console.log(`ok ${cases} ${name}`); | ||
| } | ||
|
|
||
| function no(name, detail) { | ||
| cases += 1; | ||
| console.log(`not ok ${cases} ${name}\n ${detail}`); | ||
| } | ||
|
|
||
| function browserContext() { | ||
| const sandbox = { | ||
| console, | ||
| WebAssembly, | ||
| TextEncoder, | ||
| TextDecoder, | ||
| URL, | ||
| URLSearchParams, | ||
| ArrayBuffer, | ||
| DataView, | ||
| Uint8Array, | ||
| Uint16Array, | ||
| Uint32Array, | ||
| Int8Array, | ||
| Int16Array, | ||
| Int32Array, | ||
| Float32Array, | ||
| Float64Array, | ||
| BigInt64Array, | ||
| BigUint64Array, | ||
| setTimeout, | ||
| clearTimeout, | ||
| setInterval, | ||
| clearInterval, | ||
| }; | ||
| sandbox.window = sandbox; | ||
| sandbox.self = sandbox; | ||
| return vm.createContext(sandbox); | ||
| } | ||
|
|
||
| // Drives verify_rsa_sha256_sig inside the sandbox. VM.from accepts an empty | ||
| // module and the memory is assigned directly, which is how the node suite | ||
| // reaches the host functions without a compiled contract. | ||
| const DRIVER = ` | ||
| const { Blockchain, VM, Memory } = vertLib; | ||
| function verify(signatureHex) { | ||
| const chain = new Blockchain({ chain: 'wax' }); | ||
| const memory = Memory.create(256); | ||
| const machine = VM.from(new Uint8Array(), chain); | ||
| machine._memory = memory; | ||
|
|
||
| const bytes = new Uint8Array(memory.buffer); | ||
| const encoder = new TextEncoder(); | ||
| let offset = 0; | ||
| const write = (value) => { | ||
| bytes.set(value, offset); | ||
| const at = offset; | ||
| offset += value.length + 1; | ||
| return [at, value.length]; | ||
| }; | ||
| const fromHex = (hex) => { | ||
| const out = new Uint8Array(hex.length / 2); | ||
| for (let i = 0; i < out.length; i += 1) out[i] = parseInt(hex.substr(i * 2, 2), 16); | ||
| return out; | ||
| }; | ||
|
|
||
| const [digestAt, digestLen] = write(fromHex(input.digest)); | ||
| const [signatureAt, signatureLen] = write(encoder.encode(signatureHex)); | ||
| const [exponentAt, exponentLen] = write(encoder.encode(input.exponent)); | ||
| const [modulusAt, modulusLen] = write(encoder.encode(input.modulus)); | ||
|
|
||
| return machine.imports.env.verify_rsa_sha256_sig( | ||
| digestAt, digestLen, | ||
| signatureAt, signatureLen, | ||
| exponentAt, exponentLen, | ||
| modulusAt, modulusLen, | ||
| ); | ||
| } | ||
| result = { valid: verify(input.signature), tampered: verify(input.tampered) }; | ||
| `; | ||
|
|
||
| function main() { | ||
| for (const chunk of ['externals.min.js', 'vert.min.js']) { | ||
| if (!fs.existsSync(path.join(DIST, chunk))) { | ||
| no('the browser bundle is present', `${path.join('dist-web', chunk)} is missing; run yarn run build-web`); | ||
| report(); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| const context = browserContext(); | ||
| let library; | ||
| try { | ||
| for (const chunk of ['externals.min.js', 'vert.min.js']) { | ||
| vm.runInContext(fs.readFileSync(path.join(DIST, chunk), 'utf8'), context, { filename: chunk }); | ||
| } | ||
| library = context.vert; | ||
| } catch (error) { | ||
| no('the bundle loads without node globals', String(error)); | ||
| report(); | ||
| return; | ||
| } | ||
|
|
||
| if (library && typeof library.Blockchain === 'function') { | ||
| ok('the bundle loads in a context with no Buffer, process, global or require'); | ||
| } else { | ||
| no('the bundle loads in a context with no Buffer, process, global or require', 'the UMD export is missing Blockchain'); | ||
| report(); | ||
| return; | ||
| } | ||
|
|
||
| // Sign outside the sandbox with the real node crypto, which is where a | ||
| // signature comes from in practice. | ||
| const key = new NodeRSA({ b: 1024 }); | ||
| const digest = crypto.createHash('sha256').update('Hello, RSA!').digest('hex'); | ||
| const signature = key.sign(Buffer.from(digest, 'hex'), 'hex'); | ||
| const publicKey = key.exportKey('components-public'); | ||
|
|
||
| context.vertLib = library; | ||
| context.input = { | ||
| digest, | ||
| signature, | ||
| tampered: (signature.slice(0, 2) === 'ff' ? '00' : 'ff') + signature.slice(2), | ||
| exponent: publicKey.e.toString(16), | ||
| modulus: publicKey.n.toString('hex'), | ||
| }; | ||
|
|
||
| try { | ||
| vm.runInContext(DRIVER, context, { filename: 'verify-rsa-sha256-sig' }); | ||
| } catch (error) { | ||
| no('the RSA host function runs in the bundle', String(error)); | ||
| report(); | ||
| return; | ||
| } | ||
|
|
||
| const { valid, tampered } = context.result; | ||
| if (valid === 1) { | ||
| ok('the bundle accepts a valid RSA signature'); | ||
| } else { | ||
| no('the bundle accepts a valid RSA signature', `verify_rsa_sha256_sig returned ${valid}, wanted 1`); | ||
| } | ||
| if (tampered === 0) { | ||
| ok('the bundle rejects a tampered RSA signature'); | ||
| } else { | ||
| no('the bundle rejects a tampered RSA signature', `verify_rsa_sha256_sig returned ${tampered}, wanted 0`); | ||
| } | ||
|
|
||
| report(); | ||
| } | ||
|
|
||
| function report() { | ||
| console.log(`passed ${passed}/${cases}`); | ||
| process.exit(passed === cases ? 0 : 1); | ||
| } | ||
|
|
||
| main(); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,12 +25,23 @@ module.exports = { | |
| new CleanWebpackPlugin({ cleanOnceBeforeBuildPatterns: ['**/*'] }), | ||
| new webpack.ProvidePlugin({ | ||
| Buffer: ["buffer", "Buffer"], | ||
| // node-rsa reads process.title to tell a browser from node, and webpack | ||
| // no longer supplies a process object of its own. | ||
| process: "process/browser", | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct, and it was worse than a throw. Webpack rewrites global to globalThis rather than leaving it undefined, so there is no ReferenceError: globalThis.Buffer is simply undefined, Buffer.from throws a TypeError, and the catch around the host function turns that into a return of 0. A valid signature therefore read as invalid with nothing logged. The bare identifier is not the fix here either, because Buffer in vm.ts is this package's own Uint8Array subclass from src/buffer.ts. Now importing the buffer module by name, which gives node its builtin and the bundle the package that replaces it. |
||
| }), | ||
| ], | ||
| resolve: { | ||
| extensions: ['.tsx', '.ts', '.js'], | ||
| fallback: { | ||
| assert: false, | ||
| constants: require.resolve('constants-browserify'), | ||
| crypto: require.resolve('./webpack/crypto-shim.js'), | ||
| stream: require.resolve('stream-browserify'), | ||
| // rustbn.js and colors reach for these only on their node branches, which | ||
| // a browser build never takes. | ||
| fs: false, | ||
| os: false, | ||
| path: false, | ||
| } | ||
| }, | ||
| output: { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| // node-rsa requires the node crypto module at load time in every scheme file, | ||
| // but the branches it takes when its environment is 'browser' call only | ||
| // createHash and randomBytes: the RSA arithmetic itself runs on its own | ||
| // BigInteger. Mapping crypto to this module therefore keeps the browser bundle | ||
| // to those two functions rather than pulling a whole crypto implementation in | ||
| // behind them. The engine selector reads publicEncrypt and friends off this | ||
| // object, finds them absent, and settles on the pure JavaScript engine, which | ||
| // is the one a browser has to use anyway. | ||
| module.exports = { | ||
| createHash: require('create-hash'), | ||
| randomBytes: require('randombytes'), | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed, and the smoke test is in: scripts/browser-bundle.test.js loads the emitted chunks into a context with no Buffer, no process, no global and no require, then drives verify_rsa_sha256_sig through importKey(..., 'components-public') and verify(...), asserting 1 for a valid signature and 0 for one tampered byte.
It was run against the bundle as it stood before the Buffer fix and reports 'not ok 2 the bundle accepts a valid RSA signature: returned 0, wanted 1', so it fails on the defect it exists to catch. CI runs it right after the build step.