Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/giant-dancers-sin.md
Original file line number Diff line number Diff line change
@@ -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
105 changes: 64 additions & 41 deletions packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts
Original file line number Diff line number Diff line change
@@ -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<string>;

/**
* 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(
Expand All @@ -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;
}

Expand All @@ -39,6 +30,29 @@ export class CodeSigningPlugin {
});
}

private signAsset(
asset: { source: { source(): string | Buffer } },
privateKey: Buffer,
beginMark: string,
tokenBufferSize: number
): Buffer {
const source = asset.source.source();
const content = Buffer.isBuffer(source) ? source : Buffer.from(source);

const hash = crypto
.createHash('sha256')
.update(content)
.digest('hex');
const token = jwt.sign({ hash }, privateKey, {
algorithm: 'RS256',
});

return Buffer.concat(
[content, Buffer.from(beginMark), Buffer.from(token)],
content.length + tokenBufferSize
);
}

apply(compiler: RspackCompiler): void;
apply(compiler: WebpackCompiler): void;

Expand Down Expand Up @@ -76,40 +90,49 @@ 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) => {
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 later processAssets consumers,
// such as Zephyr at REPORT (5000), receive already-signed assets
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;

logger.debug(`Signing ${file}`);
const signedBundle = this.signAsset(
asset,
privateKey,
BEGIN_CS_MARK,
TOKEN_BUFFER_SIZE
);

compilation.updateAsset(
file,
new sources.RawSource(signedBundle)
);

logger.debug(`Signed ${file}`);
}
}
}
);
await writeFileAsync(outputFilepath, signedBundle);
logger.debug(`Signed ${file}`);
}
);
}
Expand Down
93 changes: 91 additions & 2 deletions packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,7 +15,8 @@ const BUNDLE_WITH_JWT_REGEX =
async function compileBundle(
outputFilename: string,
virtualModules: Record<string, string>,
codeSigningConfig: CodeSigningPluginConfig
codeSigningConfig: CodeSigningPluginConfig,
additionalPlugins: Array<{ apply(compiler: Compiler): void }> = []
) {
const fileSystem = memfs.createFsFromVolume(new memfs.Volume());

Expand All @@ -36,6 +37,7 @@ async function compileBundle(
'package.json': '{ "type": "module" }',
...virtualModules,
}),
...additionalPlugins,
],
});

Expand Down Expand Up @@ -81,6 +83,93 @@ describe('CodeSigningPlugin', () => {
expect(chunkBundle.length).toBeGreaterThan(1280);
});

it('exposes signed chunk assets to processAssets REPORT (after ANALYSE signing)', async () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test proves assets are signed by REPORT, which is good 👍 but what about one more test for asserting the actual delta from old behavior? it might be done by checking that content at REPORT is larger than unsigned chunk or by detecting /* RCSSB */ substring in content
wdyt?

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.

Good call. I extended the test so it doesn’t only assert the JWT-shaped tail at REPORT.

There is now a processAssets tap at one stage before ANALYSE (same chunk iteration as REPORT) to snapshot the asset before CodeSigningPlugin runs. Then at REPORT we assert:

the pre-sign snapshot has no /* RCSSB / marker
the REPORT snapshot includes /
RCSSB */
REPORT content is strictly longer than the pre-sign snapshot
The existing regex checks on the chunk vs main bundle are unchanged. That should document the regression we care about: anything observing assets at REPORT must see the already signed bytes, not only the final emitted file.

const seenBeforeSigning: Record<string, string> = {};
const seenAtReportStage: Record<string, string> = {};

const captureAtReportStage = {
apply(compiler: Compiler) {
compiler.hooks.thisCompilation.tap(
'TestReportStageCapture',
(compilation) => {
const { PROCESS_ASSETS_STAGE_ANALYSE, PROCESS_ASSETS_STAGE_REPORT } =
compiler.webpack.Compilation;

/** Immediately before CodeSigningPlugin (ANALYSE / 2000) so content is still unsigned. */
const beforeSigningStage = PROCESS_ASSETS_STAGE_ANALYSE - 1;

compilation.hooks.processAssets.tap(
{
name: 'TestPreAnalyseCapture',
stage: beforeSigningStage,
},
() => {
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);
seenBeforeSigning[file] = buf.toString();
}
}
}
);

compilation.hooks.processAssets.tap(
{
name: 'TestReportStageCapture',
stage: 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]
);

const chunkFile = 'myChunk.chunk.bundle';
const before = seenBeforeSigning[chunkFile];
const atReport = seenAtReportStage[chunkFile];

expect(before).toBeDefined();
expect(atReport).toBeDefined();
/** Regression guard: signing at ANALYSE must mutate assets before REPORT (not only on emit). */
expect(before.includes('/* RCSSB */')).toBe(false);
expect(atReport.includes('/* RCSSB */')).toBe(true);
expect(atReport.length).toBeGreaterThan(before.length);

expect(atReport.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')
Expand Down
4 changes: 4 additions & 0 deletions website/src/latest/api/plugins/code-signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions website/src/v4/docs/plugins/code-signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down