Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ jobs:
- name: Build
run: yarn run build

# The browser bundle resolves node builtins through its own fallback map,
# so nothing else in this workflow would notice it breaking.
- name: Build web
run: yarn run build-web
Comment on lines +39 to +40

Copy link
Copy Markdown
Author

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.


# A compiling bundle is not a working one. This drives the RSA host
# function in a context with none of the node globals the bundle maps away.
- name: Browser bundle
run: node scripts/browser-bundle.test.js

# The examples compile contracts with blanc++, which is not available here,
# so CI runs the VM suite only.
- name: Test
Expand Down
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,12 @@
"bigint-conversion": "^2.4.3",
"buffer": "^6.0.3",
"clean-webpack-plugin": "^4.0.0",
"constants-browserify": "^1.0.0",
"create-hash": "^1.2.0",
"mocha": "^10.4.0",
"process": "^0.11.10",
"randombytes": "^2.1.0",
"stream-browserify": "^3.0.0",
"ts-loader": "^9.2.6",
"ts-node": "^10.5.0",
"tslib": "^2.3.1",
Expand All @@ -79,9 +84,9 @@
"lodash": "^4.17.21",
"loglevel": "^1.8.0",
"loglevel-plugin-prefix": "^0.8.4",
"node-rsa": "^1.1.1",
"rustbn.js": "^0.2.0",
"sorted-btree": "^1.6.0",
"node-rsa": "^1.1.1"
"sorted-btree": "^1.6.0"
},
"resolutions": {
"serialize-javascript": "^7.1.0"
Expand Down
185 changes: 185 additions & 0 deletions scripts/browser-bundle.test.js
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();
12 changes: 8 additions & 4 deletions src/antelope/vm.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import assert from "../assert";
import Buffer, { bufferToBigInt, readBufferFromBigInt } from "../buffer";
// node-rsa works on node buffers, and the Buffer above is this package's own
// Uint8Array subclass. Node resolves this import to its built-in module, and a
// bundler resolves it to the buffer package, so both get a real one.
import { Buffer as NodeBuffer } from "buffer";
import { log, Vert } from "../vert";
import { IndexObject, KeyValueObject, SecondaryKeyStore, Table } from "./table";
import { IteratorCache } from "./iterator-cache";
Expand Down Expand Up @@ -541,14 +545,14 @@ class VM extends Vert {
exponentHex (${exponent_len}): ${exponentHex}
modulusHex (${modulus_len}): ${modulusHex.substring(0, 100)}...`);

// Convert hex strings to Node.js buffers (using global Buffer, not custom Buffer)
const dataBuffer = global.Buffer.from(dataHex, 'hex');
const signatureBuffer = global.Buffer.from(signatureHex, 'hex');
// Convert hex strings to node buffers, not this package's Buffer.
const dataBuffer = NodeBuffer.from(dataHex, 'hex');
const signatureBuffer = NodeBuffer.from(signatureHex, 'hex');

// Create RSA public key from components
const key = new NodeRSA();
key.importKey({
n: global.Buffer.from(modulusHex, 'hex'),
n: NodeBuffer.from(modulusHex, 'hex'),
e: parseInt(exponentHex, 16)
}, 'components-public');

Expand Down
11 changes: 11 additions & 0 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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: {
Expand Down
12 changes: 12 additions & 0 deletions webpack/crypto-shim.js
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'),
};
Loading
Loading