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
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
96 changes: 61 additions & 35 deletions packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
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`.
*
Expand All @@ -17,7 +15,6 @@ export class CodeSigningPlugin {
constructor(private config: CodeSigningPluginConfig) {
validateConfig(config);
this.config.excludeChunks = this.config.excludeChunks ?? [];
this.chunkFilenames = new Set();
}

private shouldSignFile(
Expand All @@ -26,7 +23,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 @@ -38,6 +35,26 @@ 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 @@ -75,40 +92,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
95 changes: 93 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,95 @@ describe('CodeSigningPlugin', () => {
expect(chunkBundle.length).toBeGreaterThan(1280);
});

it('exposes signed chunk assets to processAssets REPORT (after ANALYSE signing)', async () => {
Comment thread
dannyhw marked this conversation as resolved.
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
Loading