From dc3d3febff8717956321f5f22975eb3cbed4eccf Mon Sep 17 00:00:00 2001 From: JhohellsDL Date: Sat, 11 Apr 2026 12:53:21 -0500 Subject: [PATCH] fix(CodeSigningPlugin): sign assets at processAssets ANALYSE stage before REPORT --- .changeset/giant-dancers-sin.md | 5 + .../CodeSigningPlugin/CodeSigningPlugin.ts | 98 +++++++++++-------- .../__tests__/CodeSigningPlugin.test.ts | 59 ++++++++++- .../src/latest/api/plugins/code-signing.md | 4 + website/src/v4/docs/plugins/code-signing.md | 4 + 5 files changed, 127 insertions(+), 43 deletions(-) create mode 100644 .changeset/giant-dancers-sin.md diff --git a/.changeset/giant-dancers-sin.md b/.changeset/giant-dancers-sin.md new file mode 100644 index 000000000..53427abfb --- /dev/null +++ b/.changeset/giant-dancers-sin.md @@ -0,0 +1,5 @@ +--- +"@callstack/repack": patch +--- + +Fix CodeSigningPlugin signing assets at processAssets ANALYSE stage (2000) instead of assetEmitted, ensuring bundles are signed before plugins running at REPORT stage (5000) such as withZephyr() can capture and upload them diff --git a/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts b/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts index 100c757f2..d1ecccab9 100644 --- a/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts +++ b/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts @@ -1,24 +1,15 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import util from 'node:util'; import type { Compiler as RspackCompiler } from '@rspack/core'; import jwt from 'jsonwebtoken'; import type { Compiler as WebpackCompiler } from 'webpack'; import { type CodeSigningPluginConfig, validateConfig } from './config.js'; export class CodeSigningPlugin { - private chunkFilenames: Set; - - /** - * Constructs new `RepackPlugin`. - * - * @param config Plugin configuration options. - */ constructor(private config: CodeSigningPluginConfig) { validateConfig(config); this.config.excludeChunks = this.config.excludeChunks ?? []; - this.chunkFilenames = new Set(); } private shouldSignFile( @@ -27,7 +18,7 @@ export class CodeSigningPlugin { excludedChunks: string[] | RegExp[] ): boolean { /** Exclude non-chunks & main chunk as it's always local */ - if (!this.chunkFilenames.has(file) || file === mainOutputFilename) { + if (file === mainOutputFilename) { return false; } @@ -76,40 +67,65 @@ export class CodeSigningPlugin { ? this.config.excludeChunks : [this.config.excludeChunks as RegExp]; - compiler.hooks.emit.tap('RepackCodeSigningPlugin', (compilation) => { - compilation.chunks.forEach((chunk) => { - chunk.files.forEach((file) => this.chunkFilenames.add(file)); - }); - }); - - compiler.hooks.assetEmitted.tapPromise( - { name: 'RepackCodeSigningPlugin', stage: 20 }, - async (file, { outputPath, compilation }) => { - const outputFilepath = path.join(outputPath, file); - const readFileAsync = util.promisify( - compiler.outputFileSystem!.readFile - ); - const content = (await readFileAsync(outputFilepath)) as Buffer; + compiler.hooks.thisCompilation.tap( + 'RepackCodeSigningPlugin', + (compilation) => { + // @ts-ignore — sources is available on both rspack and webpack compilers + const { sources } = compiler.webpack; const mainBundleName = compilation.outputOptions.filename as string; - if (!this.shouldSignFile(file, mainBundleName, excludedChunks)) { - return; - } - logger.debug(`Signing ${file}`); - /** generate bundle hash */ - const hash = crypto.createHash('sha256').update(content).digest('hex'); - /** generate token */ - const token = jwt.sign({ hash }, privateKey, { algorithm: 'RS256' }); - /** combine the bundle and the token */ - const signedBundle = Buffer.concat( - [content, Buffer.from(BEGIN_CS_MARK), Buffer.from(token)], - content.length + TOKEN_BUFFER_SIZE - ); - const writeFileAsync = util.promisify( - compiler.outputFileSystem!.writeFile + compilation.hooks.processAssets.tap( + { + name: 'RepackCodeSigningPlugin', + // Sign at ANALYSE (2000) so assets are signed before any plugin + // running at REPORT (5000) — e.g. withZephyr() — captures them. + // The original assetEmitted hook fires after processAssets completes, + // which is too late when Zephyr uploads assets at REPORT stage. + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE, + }, + () => { + for (const chunk of compilation.chunks) { + for (const file of chunk.files) { + if ( + !this.shouldSignFile(file, mainBundleName, excludedChunks) + ) { + continue; + } + + const asset = compilation.getAsset(file); + if (!asset) continue; + + const source = asset.source.source(); + const content = Buffer.isBuffer(source) + ? source + : Buffer.from(source); + + logger.debug(`Signing ${file}`); + /** generate bundle hash */ + const hash = crypto + .createHash('sha256') + .update(content) + .digest('hex'); + /** generate token */ + const token = jwt.sign({ hash }, privateKey, { + algorithm: 'RS256', + }); + /** combine the bundle and the token */ + const signedBundle = Buffer.concat( + [content, Buffer.from(BEGIN_CS_MARK), Buffer.from(token)], + content.length + TOKEN_BUFFER_SIZE + ); + + compilation.updateAsset( + file, + new sources.RawSource(signedBundle) + ); + + logger.debug(`Signed ${file}`); + } + } + } ); - await writeFileAsync(outputFilepath, signedBundle); - logger.debug(`Signed ${file}`); } ); } diff --git a/packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts b/packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts index f9cb007c9..d0789b7b6 100644 --- a/packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts +++ b/packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { rspack } from '@rspack/core'; +import { type Compiler, rspack } from '@rspack/core'; import jwt from 'jsonwebtoken'; import memfs from 'memfs'; import RspackVirtualModulePlugin from 'rspack-plugin-virtual-module'; @@ -15,7 +15,8 @@ const BUNDLE_WITH_JWT_REGEX = async function compileBundle( outputFilename: string, virtualModules: Record, - codeSigningConfig: CodeSigningPluginConfig + codeSigningConfig: CodeSigningPluginConfig, + additionalPlugins: Array<{ apply(compiler: Compiler): void }> = [] ) { const fileSystem = memfs.createFsFromVolume(new memfs.Volume()); @@ -36,6 +37,7 @@ async function compileBundle( 'package.json': '{ "type": "module" }', ...virtualModules, }), + ...additionalPlugins, ], }); @@ -81,6 +83,59 @@ describe('CodeSigningPlugin', () => { expect(chunkBundle.length).toBeGreaterThan(1280); }); + it('exposes signed chunk assets to processAssets REPORT (after ANALYSE signing)', async () => { + const seenAtReportStage: Record = {}; + + const captureAtReportStage = { + apply(compiler: Compiler) { + compiler.hooks.thisCompilation.tap( + 'TestReportStageCapture', + (compilation) => { + compilation.hooks.processAssets.tap( + { + name: 'TestReportStageCapture', + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + for (const chunk of compilation.chunks) { + for (const file of chunk.files) { + const asset = compilation.getAsset(file); + if (!asset) continue; + const raw = asset.source.source(); + const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw); + seenAtReportStage[file] = buf.toString(); + } + } + } + ); + } + ); + }, + }; + + await compileBundle( + 'index.bundle', + { + 'index.js': ` + const chunk = import(/* webpackChunkName: "myChunk" */'./myChunk.js'); + chunk.then(console.log); + `, + 'myChunk.js': ` + export default 'myChunk'; + `, + }, + { enabled: true, privateKeyPath: '__fixtures__/testRS256.pem' }, + [captureAtReportStage] + ); + + expect( + seenAtReportStage['myChunk.chunk.bundle']?.match(BUNDLE_WITH_JWT_REGEX) + ).toBeTruthy(); + expect( + seenAtReportStage['index.bundle']?.match(BUNDLE_WITH_JWT_REGEX) + ).toBeNull(); + }); + it('produces code-signed bundles with valid JWTs', async () => { const publicKey = fs.readFileSync( path.join(__dirname, '__fixtures__/testRS256.pem.pub') diff --git a/website/src/latest/api/plugins/code-signing.md b/website/src/latest/api/plugins/code-signing.md index d85264213..b4ed0fa3f 100644 --- a/website/src/latest/api/plugins/code-signing.md +++ b/website/src/latest/api/plugins/code-signing.md @@ -39,6 +39,10 @@ Whether to enable the plugin. You typically want to enable the plugin only for p Names of chunks to exclude from code-signing. You might want to use this if some of the chunks in your setup are not being delivered remotely and don't need to be verified. +## Behavior + +Chunk signatures are applied during `processAssets` at the `ANALYSE` stage (2000), before later stages of the same hook. This ensures that plugins or tooling that capture or upload chunk outputs at subsequent stages — such as `withZephyr()` which runs at `REPORT` stage (5000) — receive bundles that already include the signature. + ## Guide To add code-signing to your app, you first need to generate a pair of cryptographic keys that will be used for both signing the bundles (private key) and verifying their integrity in runtime. diff --git a/website/src/v4/docs/plugins/code-signing.md b/website/src/v4/docs/plugins/code-signing.md index f693223d7..5a671d4ab 100644 --- a/website/src/v4/docs/plugins/code-signing.md +++ b/website/src/v4/docs/plugins/code-signing.md @@ -37,6 +37,10 @@ Whether to enable the plugin. You typically want to enable the plugin only for p Names of chunks to exclude from code-signing. You might want to use this if some of the chunks in your setup are not being delivered remotely and don't need to be verified. +## Behavior + +Chunk signatures are applied during `processAssets` at the `ANALYSE` stage (2000), before later stages of the same hook. This ensures that plugins or tooling that capture or upload chunk outputs at subsequent stages — such as `withZephyr()` which runs at `REPORT` stage (5000) — receive bundles that already include the signature. + ## Guide To add code-signing to your app, you first need to generate a pair of cryptographic keys that will be used for both signing the bundles (private key) and verifying their integrity in runtime.