From 7545f75bd38a34d33fedb6866932f9a95385520d Mon Sep 17 00:00:00 2001 From: bartekkrok Date: Fri, 17 Apr 2026 12:50:07 +0200 Subject: [PATCH 1/6] feat(CodeSigningPlugin): auto-embed public key into native project files Add publicKeyPath and nativeProjectPaths options to CodeSigningPlugin. When publicKeyPath is set, the plugin automatically embeds the public key into iOS Info.plist and Android strings.xml during compilation, removing the need for manual setup. Also exports embedPublicKey as a standalone utility. --- .../CodeSigningPlugin/CodeSigningPlugin.ts | 63 ++- .../src/plugins/CodeSigningPlugin/config.ts | 27 ++ .../CodeSigningPlugin/embedPublicKey.ts | 200 +++++++++ .../src/plugins/CodeSigningPlugin/index.ts | 5 + .../__tests__/CodeSigningPlugin.test.ts | 207 +++++++++- .../plugins/__tests__/embedPublicKey.test.ts | 383 ++++++++++++++++++ .../src/latest/api/plugins/code-signing.md | 87 +++- website/src/v4/docs/plugins/code-signing.md | 91 ++++- 8 files changed, 1048 insertions(+), 15 deletions(-) create mode 100644 packages/repack/src/plugins/CodeSigningPlugin/embedPublicKey.ts create mode 100644 packages/repack/src/plugins/__tests__/embedPublicKey.test.ts diff --git a/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts b/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts index 100c757f2..61cdeae2a 100644 --- a/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts +++ b/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts @@ -6,6 +6,7 @@ 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'; +import { embedPublicKey } from './embedPublicKey.js'; export class CodeSigningPlugin { private chunkFilenames: Set; @@ -26,7 +27,6 @@ export class CodeSigningPlugin { mainOutputFilename: string, excludedChunks: string[] | RegExp[] ): boolean { - /** Exclude non-chunks & main chunk as it's always local */ if (!this.chunkFilenames.has(file) || file === mainOutputFilename) { return false; } @@ -39,6 +39,65 @@ export class CodeSigningPlugin { }); } + private embedPublicKeyInNativeProjects(compiler: RspackCompiler) { + if (!this.config.publicKeyPath) { + return; + } + + const logger = compiler.getInfrastructureLogger('RepackCodeSigningPlugin'); + const projectRoot = compiler.context; + + const publicKeyPath = path.isAbsolute(this.config.publicKeyPath) + ? this.config.publicKeyPath + : path.resolve(projectRoot, this.config.publicKeyPath); + + if (!fs.existsSync(publicKeyPath)) { + logger.warn( + `Public key not found at ${publicKeyPath}. ` + + 'Skipping automatic embedding into native project files.' + ); + return; + } + + const result = embedPublicKey({ + publicKeyPath, + projectRoot, + iosInfoPlistPath: this.config.nativeProjectPaths?.ios + ? path.isAbsolute(this.config.nativeProjectPaths.ios) + ? this.config.nativeProjectPaths.ios + : path.resolve(projectRoot, this.config.nativeProjectPaths.ios) + : undefined, + androidStringsXmlPath: this.config.nativeProjectPaths?.android + ? path.isAbsolute(this.config.nativeProjectPaths.android) + ? this.config.nativeProjectPaths.android + : path.resolve(projectRoot, this.config.nativeProjectPaths.android) + : undefined, + }); + + if (result.ios.modified) { + logger.info(`Embedded public key in iOS Info.plist: ${result.ios.path}`); + } else if (result.ios.error) { + logger.warn(`Failed to embed public key in iOS: ${result.ios.error}`); + } + + if (result.android.modified) { + logger.info( + `Embedded public key in Android strings.xml: ${result.android.path}` + ); + } else if (result.android.error) { + logger.warn( + `Failed to embed public key in Android: ${result.android.error}` + ); + } + + if (!result.ios.modified && !result.android.modified && !result.ios.error && !result.android.error) { + logger.warn( + 'No native project files found. Use nativeProjectPaths to specify custom paths ' + + 'or manually add the public key to Info.plist / strings.xml.' + ); + } + } + apply(compiler: RspackCompiler): void; apply(compiler: WebpackCompiler): void; @@ -51,6 +110,8 @@ export class CodeSigningPlugin { return; } + this.embedPublicKeyInNativeProjects(compiler); + if (typeof compiler.options.output.filename === 'function') { throw new Error( '[RepackCodeSigningPlugin] Dynamic output filename is not supported. Please use static filename instead.' diff --git a/packages/repack/src/plugins/CodeSigningPlugin/config.ts b/packages/repack/src/plugins/CodeSigningPlugin/config.ts index 38a5e0afa..ed485d69c 100644 --- a/packages/repack/src/plugins/CodeSigningPlugin/config.ts +++ b/packages/repack/src/plugins/CodeSigningPlugin/config.ts @@ -10,6 +10,24 @@ export interface CodeSigningPluginConfig { privateKeyPath: string; /** Names of chunks to exclude from being signed. */ excludeChunks?: string[] | RegExp | RegExp[]; + /** + * Path to the public key file. When provided, the plugin will automatically + * embed the public key into native project files (Info.plist for iOS, + * strings.xml for Android) so that the runtime can verify signed bundles. + * + * Relative paths are resolved from the project root (compiler context). + */ + publicKeyPath?: string; + /** + * Override auto-detected paths to native project files where the public key + * should be embedded. Only used when `publicKeyPath` is set. + */ + nativeProjectPaths?: { + /** Path to iOS Info.plist. Auto-detected if not provided. */ + ios?: string; + /** Path to Android strings.xml. Auto-detected if not provided. */ + android?: string; + }; } type Schema = Parameters[0]; @@ -38,6 +56,15 @@ export const optionsSchema: Schema = { }, ], }, + publicKeyPath: { type: 'string' }, + nativeProjectPaths: { + type: 'object', + properties: { + ios: { type: 'string' }, + android: { type: 'string' }, + }, + additionalProperties: false, + }, }, required: ['privateKeyPath'], additionalProperties: false, diff --git a/packages/repack/src/plugins/CodeSigningPlugin/embedPublicKey.ts b/packages/repack/src/plugins/CodeSigningPlugin/embedPublicKey.ts new file mode 100644 index 000000000..caf38e16e --- /dev/null +++ b/packages/repack/src/plugins/CodeSigningPlugin/embedPublicKey.ts @@ -0,0 +1,200 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export interface EmbedPublicKeyConfig { + /** Absolute path to the public key file. */ + publicKeyPath: string; + /** Absolute path to the project root. */ + projectRoot: string; + /** Custom path to iOS Info.plist. Auto-detected if not provided. */ + iosInfoPlistPath?: string; + /** Custom path to Android strings.xml. Auto-detected if not provided. */ + androidStringsXmlPath?: string; +} + +export interface EmbedPublicKeyResult { + ios: { modified: boolean; path?: string; error?: string }; + android: { modified: boolean; path?: string; error?: string }; +} + +/** + * Embeds the Re.Pack code-signing public key into native project files. + * Modifies `Info.plist` (iOS) and `strings.xml` (Android) so the runtime + * can verify signed bundles without manual file editing. + */ +export function embedPublicKey(config: EmbedPublicKeyConfig): EmbedPublicKeyResult { + const publicKey = fs.readFileSync(config.publicKeyPath, 'utf-8').trim(); + + const result: EmbedPublicKeyResult = { + ios: { modified: false }, + android: { modified: false }, + }; + + const plistPath = + config.iosInfoPlistPath ?? + findIOSInfoPlistPath(config.projectRoot); + + if (plistPath) { + try { + embedPublicKeyInPlist(publicKey, plistPath); + result.ios = { modified: true, path: plistPath }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + result.ios = { modified: false, path: plistPath, error: message }; + } + } + + const stringsXmlPath = + config.androidStringsXmlPath ?? + findAndroidStringsXmlPath(config.projectRoot); + + if (stringsXmlPath) { + try { + embedPublicKeyInStringsXml(publicKey, stringsXmlPath); + result.android = { modified: true, path: stringsXmlPath }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + result.android = { modified: false, path: stringsXmlPath, error: message }; + } + } + + return result; +} + +/** + * Searches for `Info.plist` inside `ios//Info.plist`. + * Returns the first match or `null`. + */ +export function findIOSInfoPlistPath(projectRoot: string): string | null { + const iosDir = path.join(projectRoot, 'ios'); + if (!fs.existsSync(iosDir)) { + return null; + } + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(iosDir, { withFileTypes: true }); + } catch { + return null; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + // Skip common non-app directories + if (entry.name === 'Pods' || entry.name === 'build' || entry.name.endsWith('.xcodeproj') || entry.name.endsWith('.xcworkspace')) { + continue; + } + const plistPath = path.join(iosDir, entry.name, 'Info.plist'); + if (fs.existsSync(plistPath)) { + return plistPath; + } + } + + return null; +} + +/** + * Returns the standard path to `strings.xml` if it exists, or `null`. + */ +export function findAndroidStringsXmlPath(projectRoot: string): string | null { + const stringsPath = path.join( + projectRoot, + 'android', + 'app', + 'src', + 'main', + 'res', + 'values', + 'strings.xml' + ); + return fs.existsSync(stringsPath) ? stringsPath : null; +} + +/** + * Embeds or updates `RepackPublicKey` in an iOS `Info.plist` file. + */ +export function embedPublicKeyInPlist( + publicKey: string, + plistPath: string +): void { + let content = fs.readFileSync(plistPath, 'utf-8'); + + const existingKeyPattern = + /[ \t]*RepackPublicKey<\/key>\s*[\s\S]*?<\/string>/; + + const replacement = + '\tRepackPublicKey\n' + + `\t${escapeXml(publicKey)}`; + + if (existingKeyPattern.test(content)) { + content = content.replace(existingKeyPattern, replacement); + } else { + const insertIdx = content.lastIndexOf(''); + if (insertIdx === -1) { + throw new Error( + `[CodeSigningPlugin] Could not find in ${plistPath}. ` + + 'The file may not be a valid Info.plist.' + ); + } + content = + content.slice(0, insertIdx) + + replacement + + '\n' + + content.slice(insertIdx); + } + + fs.writeFileSync(plistPath, content, 'utf-8'); +} + +/** + * Embeds or updates `RepackPublicKey` in an Android `strings.xml` file. + * Creates the file if it does not exist. + */ +export function embedPublicKeyInStringsXml( + publicKey: string, + stringsXmlPath: string +): void { + const escapedKey = escapeXml(publicKey); + const newEntry = ` ${escapedKey}`; + + if (!fs.existsSync(stringsXmlPath)) { + const dir = path.dirname(stringsXmlPath); + fs.mkdirSync(dir, { recursive: true }); + const content = + '\n' + + '\n' + + newEntry + + '\n' + + '\n'; + fs.writeFileSync(stringsXmlPath, content, 'utf-8'); + return; + } + + let content = fs.readFileSync(stringsXmlPath, 'utf-8'); + + const existingPattern = + /[ \t]*]*>[\s\S]*?<\/string>/; + + if (existingPattern.test(content)) { + content = content.replace(existingPattern, newEntry); + } else { + const insertIdx = content.lastIndexOf(''); + if (insertIdx === -1) { + throw new Error( + `[CodeSigningPlugin] Could not find in ${stringsXmlPath}. ` + + 'The file may not be a valid strings.xml.' + ); + } + content = + content.slice(0, insertIdx) + + newEntry + + '\n' + + content.slice(insertIdx); + } + + fs.writeFileSync(stringsXmlPath, content, 'utf-8'); +} + +function escapeXml(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>'); +} diff --git a/packages/repack/src/plugins/CodeSigningPlugin/index.ts b/packages/repack/src/plugins/CodeSigningPlugin/index.ts index 22980129d..4146f2331 100644 --- a/packages/repack/src/plugins/CodeSigningPlugin/index.ts +++ b/packages/repack/src/plugins/CodeSigningPlugin/index.ts @@ -1,2 +1,7 @@ export { CodeSigningPlugin } from './CodeSigningPlugin.js'; export type { CodeSigningPluginConfig } from './config.js'; +export { embedPublicKey } from './embedPublicKey.js'; +export type { + EmbedPublicKeyConfig, + EmbedPublicKeyResult, +} from './embedPublicKey.js'; diff --git a/packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts b/packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts index f9cb007c9..dacc3cc70 100644 --- a/packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts +++ b/packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { rspack } from '@rspack/core'; import jwt from 'jsonwebtoken'; @@ -15,12 +16,13 @@ const BUNDLE_WITH_JWT_REGEX = async function compileBundle( outputFilename: string, virtualModules: Record, - codeSigningConfig: CodeSigningPluginConfig + codeSigningConfig: CodeSigningPluginConfig, + context?: string ) { const fileSystem = memfs.createFsFromVolume(new memfs.Volume()); const compiler = rspack({ - context: __dirname, + context: context ?? __dirname, mode: 'production', devtool: false, entry: 'index.js', @@ -214,3 +216,204 @@ describe('CodeSigningPlugin', () => { ).rejects.toThrow(/Invalid configuration object/); }); }); + +describe('CodeSigningPlugin - public key embedding', () => { + let tmpDir: string; + + function createTempProjectDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-cs-plugin-')); + return dir; + } + + beforeEach(() => { + tmpDir = createTempProjectDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function setupNativeFiles(projectRoot: string) { + const iosAppDir = path.join(projectRoot, 'ios', 'TestApp'); + fs.mkdirSync(iosAppDir, { recursive: true }); + fs.writeFileSync( + path.join(iosAppDir, 'Info.plist'), + ` + + + +\tCFBundleName +\tTestApp + +` + ); + + const androidValuesDir = path.join( + projectRoot, + 'android', + 'app', + 'src', + 'main', + 'res', + 'values' + ); + fs.mkdirSync(androidValuesDir, { recursive: true }); + fs.writeFileSync( + path.join(androidValuesDir, 'strings.xml'), + ` + + TestApp +` + ); + } + + function copyFixtureKeys(projectRoot: string) { + const fixtureDir = path.join(__dirname, '__fixtures__'); + fs.copyFileSync( + path.join(fixtureDir, 'testRS256.pem'), + path.join(projectRoot, 'code-signing.pem') + ); + fs.copyFileSync( + path.join(fixtureDir, 'testRS256.pem.pub'), + path.join(projectRoot, 'code-signing.pem.pub') + ); + } + + it('does not embed when publicKeyPath is not set', async () => { + copyFixtureKeys(tmpDir); + setupNativeFiles(tmpDir); + + 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: path.join(tmpDir, 'code-signing.pem'), + }, + tmpDir + ); + + const plistContent = fs.readFileSync( + path.join(tmpDir, 'ios', 'TestApp', 'Info.plist'), + 'utf-8' + ); + expect(plistContent).not.toContain('RepackPublicKey'); + + const stringsContent = fs.readFileSync( + path.join( + tmpDir, + 'android', + 'app', + 'src', + 'main', + 'res', + 'values', + 'strings.xml' + ), + 'utf-8' + ); + expect(stringsContent).not.toContain('RepackPublicKey'); + }); + + it('does not embed when enabled is false', async () => { + copyFixtureKeys(tmpDir); + setupNativeFiles(tmpDir); + + await compileBundle( + 'index.bundle', + { + 'index.js': ` + const chunk = import(/* webpackChunkName: "myChunk" */'./myChunk.js'); + chunk.then(console.log); + `, + 'myChunk.js': ` + export default 'myChunk'; + `, + }, + { + enabled: false, + privateKeyPath: path.join(tmpDir, 'code-signing.pem'), + publicKeyPath: path.join(tmpDir, 'code-signing.pem.pub'), + }, + tmpDir + ); + + const plistContent = fs.readFileSync( + path.join(tmpDir, 'ios', 'TestApp', 'Info.plist'), + 'utf-8' + ); + expect(plistContent).not.toContain('RepackPublicKey'); + }); + + it('still signs chunks correctly when publicKeyPath is also provided', async () => { + copyFixtureKeys(tmpDir); + setupNativeFiles(tmpDir); + + const publicKey = fs.readFileSync( + path.join(tmpDir, 'code-signing.pem.pub') + ); + + const { getBundle } = 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: path.join(tmpDir, 'code-signing.pem'), + publicKeyPath: path.join(tmpDir, 'code-signing.pem.pub'), + }, + tmpDir + ); + + const chunkBundle = getBundle('myChunk.chunk.bundle'); + expect(chunkBundle.toString().match(BUNDLE_WITH_JWT_REGEX)).toBeTruthy(); + + const token = chunkBundle + .toString() + .split('/* RCSSB */')[1] + .replace(/\0/g, ''); + + const payload = jwt.verify(token, publicKey) as jwt.JwtPayload; + expect(payload).toHaveProperty('hash'); + }); + + it('accepts publicKeyPath and nativeProjectPaths in schema validation', () => { + expect( + () => + new CodeSigningPlugin({ + privateKeyPath: '__fixtures__/testRS256.pem', + publicKeyPath: '__fixtures__/testRS256.pem.pub', + nativeProjectPaths: { + ios: './ios/App/Info.plist', + android: './android/app/src/main/res/values/strings.xml', + }, + }) + ).not.toThrow(); + }); + + it('rejects invalid nativeProjectPaths schema', () => { + expect( + () => + new CodeSigningPlugin({ + privateKeyPath: '__fixtures__/testRS256.pem', + // @ts-expect-error invalid nativeProjectPaths on purpose + nativeProjectPaths: { web: './web/index.html' }, + }) + ).toThrow(/Invalid configuration object/); + }); +}); diff --git a/packages/repack/src/plugins/__tests__/embedPublicKey.test.ts b/packages/repack/src/plugins/__tests__/embedPublicKey.test.ts new file mode 100644 index 000000000..19be195d5 --- /dev/null +++ b/packages/repack/src/plugins/__tests__/embedPublicKey.test.ts @@ -0,0 +1,383 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + embedPublicKey, + embedPublicKeyInPlist, + embedPublicKeyInStringsXml, + findAndroidStringsXmlPath, + findIOSInfoPlistPath, +} from '../CodeSigningPlugin/embedPublicKey.js'; + +const SAMPLE_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Z3VS5JJcds3xfn/ygWe +FJwMGMIZ+PbSmUXzpFbz0YjJZHQmRm9LTjg0Ij5kbBgB/TDH5mvIhkP6sBTVKCh +-----END PUBLIC KEY-----`; + +function createTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'repack-cs-test-')); +} + +function cleanupTempDir(dir: string): void { + fs.rmSync(dir, { recursive: true, force: true }); +} + +describe('embedPublicKeyInPlist', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tmpDir); + }); + + it('adds RepackPublicKey to an Info.plist without existing key', () => { + const plistPath = path.join(tmpDir, 'Info.plist'); + fs.writeFileSync( + plistPath, + ` + + + +\tCFBundleName +\tTestApp + +` + ); + + embedPublicKeyInPlist(SAMPLE_PUBLIC_KEY, plistPath); + const result = fs.readFileSync(plistPath, 'utf-8'); + + expect(result).toContain('RepackPublicKey'); + expect(result).toContain('-----BEGIN PUBLIC KEY-----'); + expect(result).toContain('-----END PUBLIC KEY-----'); + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('updates existing RepackPublicKey in Info.plist', () => { + const plistPath = path.join(tmpDir, 'Info.plist'); + fs.writeFileSync( + plistPath, + ` + + +\tRepackPublicKey +\tOLD_KEY_CONTENT +\tCFBundleName +\tTestApp + +` + ); + + embedPublicKeyInPlist(SAMPLE_PUBLIC_KEY, plistPath); + const result = fs.readFileSync(plistPath, 'utf-8'); + + expect(result).not.toContain('OLD_KEY_CONTENT'); + expect(result).toContain('-----BEGIN PUBLIC KEY-----'); + expect(result).toContain('CFBundleName'); + }); + + it('throws when plist has no tag', () => { + const plistPath = path.join(tmpDir, 'Info.plist'); + fs.writeFileSync(plistPath, ''); + + expect(() => embedPublicKeyInPlist(SAMPLE_PUBLIC_KEY, plistPath)).toThrow( + /Could not find <\/dict>/ + ); + }); + + it('escapes XML special characters in the public key', () => { + const plistPath = path.join(tmpDir, 'Info.plist'); + fs.writeFileSync( + plistPath, + ` + + + +` + ); + + embedPublicKeyInPlist('key&special', plistPath); + const result = fs.readFileSync(plistPath, 'utf-8'); + + expect(result).toContain('key<with>&special'); + }); +}); + +describe('embedPublicKeyInStringsXml', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tmpDir); + }); + + it('adds RepackPublicKey to an existing strings.xml', () => { + const xmlPath = path.join(tmpDir, 'strings.xml'); + fs.writeFileSync( + xmlPath, + ` + + TestApp +` + ); + + embedPublicKeyInStringsXml(SAMPLE_PUBLIC_KEY, xmlPath); + const result = fs.readFileSync(xmlPath, 'utf-8'); + + expect(result).toContain('name="RepackPublicKey"'); + expect(result).toContain('translatable="false"'); + expect(result).toContain('-----BEGIN PUBLIC KEY-----'); + expect(result).toContain(''); + expect(result).toContain('name="app_name"'); + }); + + it('updates existing RepackPublicKey in strings.xml', () => { + const xmlPath = path.join(tmpDir, 'strings.xml'); + fs.writeFileSync( + xmlPath, + ` + + OLD_KEY + TestApp +` + ); + + embedPublicKeyInStringsXml(SAMPLE_PUBLIC_KEY, xmlPath); + const result = fs.readFileSync(xmlPath, 'utf-8'); + + expect(result).not.toContain('OLD_KEY'); + expect(result).toContain('-----BEGIN PUBLIC KEY-----'); + expect(result).toContain('name="app_name"'); + }); + + it('creates strings.xml if it does not exist', () => { + const valuesDir = path.join(tmpDir, 'res', 'values'); + const xmlPath = path.join(valuesDir, 'strings.xml'); + + embedPublicKeyInStringsXml(SAMPLE_PUBLIC_KEY, xmlPath); + const result = fs.readFileSync(xmlPath, 'utf-8'); + + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain('name="RepackPublicKey"'); + expect(result).toContain(''); + }); + + it('throws when strings.xml has no tag', () => { + const xmlPath = path.join(tmpDir, 'strings.xml'); + fs.writeFileSync(xmlPath, 'content'); + + expect(() => + embedPublicKeyInStringsXml(SAMPLE_PUBLIC_KEY, xmlPath) + ).toThrow(/Could not find <\/resources>/); + }); +}); + +describe('findIOSInfoPlistPath', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tmpDir); + }); + + it('finds Info.plist in ios// directory', () => { + const appDir = path.join(tmpDir, 'ios', 'MyApp'); + fs.mkdirSync(appDir, { recursive: true }); + const plistPath = path.join(appDir, 'Info.plist'); + fs.writeFileSync(plistPath, ''); + + expect(findIOSInfoPlistPath(tmpDir)).toBe(plistPath); + }); + + it('skips Pods and build directories', () => { + const podsDir = path.join(tmpDir, 'ios', 'Pods'); + fs.mkdirSync(podsDir, { recursive: true }); + fs.writeFileSync(path.join(podsDir, 'Info.plist'), ''); + + const buildDir = path.join(tmpDir, 'ios', 'build'); + fs.mkdirSync(buildDir, { recursive: true }); + fs.writeFileSync(path.join(buildDir, 'Info.plist'), ''); + + expect(findIOSInfoPlistPath(tmpDir)).toBeNull(); + }); + + it('returns null when ios directory does not exist', () => { + expect(findIOSInfoPlistPath(tmpDir)).toBeNull(); + }); +}); + +describe('findAndroidStringsXmlPath', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tmpDir); + }); + + it('finds strings.xml at standard location', () => { + const valuesDir = path.join( + tmpDir, + 'android', + 'app', + 'src', + 'main', + 'res', + 'values' + ); + fs.mkdirSync(valuesDir, { recursive: true }); + const xmlPath = path.join(valuesDir, 'strings.xml'); + fs.writeFileSync(xmlPath, ''); + + expect(findAndroidStringsXmlPath(tmpDir)).toBe(xmlPath); + }); + + it('returns null when strings.xml does not exist', () => { + expect(findAndroidStringsXmlPath(tmpDir)).toBeNull(); + }); +}); + +describe('embedPublicKey', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tmpDir); + }); + + it('embeds public key in both iOS and Android files', () => { + const keyPath = path.join(tmpDir, 'code-signing.pem.pub'); + fs.writeFileSync(keyPath, SAMPLE_PUBLIC_KEY); + + const iosAppDir = path.join(tmpDir, 'ios', 'TestApp'); + fs.mkdirSync(iosAppDir, { recursive: true }); + const plistPath = path.join(iosAppDir, 'Info.plist'); + fs.writeFileSync( + plistPath, + ` + + +\tCFBundleName +\tTestApp + +` + ); + + const androidValuesDir = path.join( + tmpDir, + 'android', + 'app', + 'src', + 'main', + 'res', + 'values' + ); + fs.mkdirSync(androidValuesDir, { recursive: true }); + const stringsPath = path.join(androidValuesDir, 'strings.xml'); + fs.writeFileSync( + stringsPath, + ` + + TestApp +` + ); + + const result = embedPublicKey({ + publicKeyPath: keyPath, + projectRoot: tmpDir, + }); + + expect(result.ios.modified).toBe(true); + expect(result.ios.path).toBe(plistPath); + expect(result.android.modified).toBe(true); + expect(result.android.path).toBe(stringsPath); + + const plistContent = fs.readFileSync(plistPath, 'utf-8'); + expect(plistContent).toContain('RepackPublicKey'); + + const stringsContent = fs.readFileSync(stringsPath, 'utf-8'); + expect(stringsContent).toContain('RepackPublicKey'); + }); + + it('uses custom native project paths when provided', () => { + const keyPath = path.join(tmpDir, 'key.pub'); + fs.writeFileSync(keyPath, SAMPLE_PUBLIC_KEY); + + const customPlistPath = path.join(tmpDir, 'custom', 'Info.plist'); + fs.mkdirSync(path.dirname(customPlistPath), { recursive: true }); + fs.writeFileSync( + customPlistPath, + ` + + + +` + ); + + const customStringsPath = path.join(tmpDir, 'custom', 'strings.xml'); + fs.writeFileSync( + customStringsPath, + ` + +` + ); + + const result = embedPublicKey({ + publicKeyPath: keyPath, + projectRoot: tmpDir, + iosInfoPlistPath: customPlistPath, + androidStringsXmlPath: customStringsPath, + }); + + expect(result.ios.modified).toBe(true); + expect(result.ios.path).toBe(customPlistPath); + expect(result.android.modified).toBe(true); + expect(result.android.path).toBe(customStringsPath); + }); + + it('handles missing native project files gracefully', () => { + const keyPath = path.join(tmpDir, 'key.pub'); + fs.writeFileSync(keyPath, SAMPLE_PUBLIC_KEY); + + const result = embedPublicKey({ + publicKeyPath: keyPath, + projectRoot: tmpDir, + }); + + expect(result.ios.modified).toBe(false); + expect(result.android.modified).toBe(false); + }); + + it('reports errors without crashing', () => { + const keyPath = path.join(tmpDir, 'key.pub'); + fs.writeFileSync(keyPath, SAMPLE_PUBLIC_KEY); + + const brokenPlistPath = path.join(tmpDir, 'broken.plist'); + fs.writeFileSync(brokenPlistPath, ''); + + const result = embedPublicKey({ + publicKeyPath: keyPath, + projectRoot: tmpDir, + iosInfoPlistPath: brokenPlistPath, + }); + + expect(result.ios.modified).toBe(false); + expect(result.ios.error).toContain('Could not find '); + }); +}); diff --git a/website/src/latest/api/plugins/code-signing.md b/website/src/latest/api/plugins/code-signing.md index d85264213..bd41e8e1e 100644 --- a/website/src/latest/api/plugins/code-signing.md +++ b/website/src/latest/api/plugins/code-signing.md @@ -39,6 +39,23 @@ 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. +### publicKeyPath + +- Type: `string` + +Path to the public key file. When provided, the plugin will automatically embed the public key into native project files (`Info.plist` for iOS, `strings.xml` for Android) so that the runtime can verify signed bundles without manual file editing. + +Relative paths are resolved from the project root (compiler context). + +### nativeProjectPaths + +- Type: `{ ios?: string; android?: string }` + +Override auto-detected paths to native project files where the public key should be embedded. Only used when `publicKeyPath` is set. + +- `ios` — Path to `Info.plist`. Auto-detected from `ios//Info.plist` if not provided. +- `android` — Path to `strings.xml`. Auto-detected from `android/app/src/main/res/values/strings.xml` if not provided. + ## 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. @@ -62,7 +79,7 @@ The passphrase must be left empty. After that, you need to add `CodeSigningPlugin` to your configuration. Make sure the `privateKeyPath` points to the location of your `code-signing.pem`. -```js title="rspack.config.cjs" {8-11} +```js title="rspack.config.cjs" {8-12} const Repack = require("@callstack/repack"); module.exports = (env) => { @@ -73,6 +90,7 @@ module.exports = (env) => { new Repack.plugins.CodeSigningPlugin({ enabled: mode === "production", privateKeyPath: "./code-signing.pem", + publicKeyPath: "./code-signing.pem.pub", }), ], }; @@ -81,11 +99,68 @@ module.exports = (env) => { ### Add the public key -To be able to verify the bundles in runtime, we need to add the public key (`code-signing.pem.pub`) to the app assets. The public key needs to be included for every platform separately. +To be able to verify the bundles in runtime, the public key (`code-signing.pem.pub`) needs to be added to the native project files so that the app can verify signed bundles. + +#### Automatic embedding (recommended) + +When `publicKeyPath` is provided in the plugin configuration (as shown above), the plugin will **automatically** embed the public key into your native project files: + +- **iOS**: Adds `RepackPublicKey` entry to `ios//Info.plist` +- **Android**: Adds `RepackPublicKey` string resource to `android/app/src/main/res/values/strings.xml` + +The plugin auto-detects the correct file paths. If your project has a non-standard directory structure, you can specify custom paths: + +```js title="rspack.config.cjs" {12-15} +const Repack = require("@callstack/repack"); + +module.exports = (env) => { + const { mode } = env; + return { + plugins: [ + new Repack.RepackPlugin(), + new Repack.plugins.CodeSigningPlugin({ + enabled: mode === "production", + privateKeyPath: "./code-signing.pem", + publicKeyPath: "./code-signing.pem.pub", + nativeProjectPaths: { + ios: "./ios/MyApp/Info.plist", + android: "./android/app/src/main/res/values/strings.xml", + }, + }), + ], + }; +}; +``` + +:::info + +The automatic embedding modifies your source files in-place. After the first build with `publicKeyPath` set, the native files will contain the public key and subsequent builds will reuse it. If you change the key pair, the plugin will update the files automatically on the next build. + +::: + +#### Standalone usage + +You can also use the `embedPublicKey` function independently of the plugin, for example in a setup script: + +```js title="setup-code-signing.js" +const { plugins } = require("@callstack/repack"); + +const result = plugins.embedPublicKey({ + publicKeyPath: "./code-signing.pem.pub", + projectRoot: __dirname, +}); + +console.log("iOS:", result.ios); +console.log("Android:", result.android); +``` + +#### Manual setup + +If you prefer to add the public key manually (or if automatic detection doesn't work for your project structure), you can follow the steps below. -#### iOS +##### iOS -You need to add the public key to `ios//Info.plist` under the name `RepackPublicKey`. Add the following to your `Info.plist` and then copy the contents of `code-signing.pem.pub` and paste them inside of the `` tags: +Add the public key to `ios//Info.plist` under the name `RepackPublicKey`. Add the following to your `Info.plist` and then copy the contents of `code-signing.pem.pub` and paste them inside of the `` tags: ```xml title="Info.plist" @@ -98,9 +173,9 @@ You need to add the public key to `ios//Info.plist` under the name `Rep ``` -#### Android +##### Android -You need to add the public key to `android/app/src/main/res/values/strings.xml` under the name `RepackPublicKey`. Add the following to your `strings.xml` and then copy the contents of `code-signing.pem.pub` and paste them inside of the `` tags: +Add the public key to `android/app/src/main/res/values/strings.xml` under the name `RepackPublicKey`. Add the following to your `strings.xml` and then copy the contents of `code-signing.pem.pub` and paste them inside of the `` tags: ```xml title="strings.xml" diff --git a/website/src/v4/docs/plugins/code-signing.md b/website/src/v4/docs/plugins/code-signing.md index f693223d7..69c28fcb9 100644 --- a/website/src/v4/docs/plugins/code-signing.md +++ b/website/src/v4/docs/plugins/code-signing.md @@ -37,6 +37,23 @@ 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. +### publicKeyPath + +- Type: `string` + +Path to the public key file. When provided, the plugin will automatically embed the public key into native project files (`Info.plist` for iOS, `strings.xml` for Android) so that the runtime can verify signed bundles without manual file editing. + +Relative paths are resolved from the project root (compiler context). + +### nativeProjectPaths + +- Type: `{ ios?: string; android?: string }` + +Override auto-detected paths to native project files where the public key should be embedded. Only used when `publicKeyPath` is set. + +- `ios` — Path to `Info.plist`. Auto-detected from `ios//Info.plist` if not provided. +- `android` — Path to `strings.xml`. Auto-detected from `android/app/src/main/res/values/strings.xml` if not provided. + ## 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. @@ -54,7 +71,7 @@ openssl rsa -in code-signing.pem -pubout -outform PEM -out code-signing.pem.pub After that, you need to add `CodeSigningPlugin` to your configuration. Make sure the `privateKeyPath` points to the location of your `code-signing.pem`. -```js title="webpack.config.js" {14-17} +```js title="webpack.config.js" {14-18} // ... plugins: [ new Repack.RepackPlugin({ @@ -71,17 +88,79 @@ plugins: [ new Repack.plugins.CodeSigningPlugin({ enabled: mode === 'production', privateKeyPath: './code-signing.pem', + publicKeyPath: './code-signing.pem.pub', }), ]; ``` ### Add the public key -To be able to verify the bundles in runtime, we need to add the public key (`code-signing.pem.pub`) to the app assets. The public key needs to be included for every platform separately. +To be able to verify the bundles in runtime, the public key (`code-signing.pem.pub`) needs to be added to the native project files so that the app can verify signed bundles. + +#### Automatic embedding (recommended) + +When `publicKeyPath` is provided in the plugin configuration (as shown above), the plugin will **automatically** embed the public key into your native project files: + +- **iOS**: Adds `RepackPublicKey` entry to `ios//Info.plist` +- **Android**: Adds `RepackPublicKey` string resource to `android/app/src/main/res/values/strings.xml` + +The plugin auto-detects the correct file paths. If your project has a non-standard directory structure, you can specify custom paths using the `nativeProjectPaths` option: + +```js title="webpack.config.js" {18-21} +// ... +plugins: [ + new Repack.RepackPlugin({ + context, + mode, + platform, + devServer, + output: { + bundleFilename, + sourceMapFilename, + assetsPath, + }, + }), + new Repack.plugins.CodeSigningPlugin({ + enabled: mode === 'production', + privateKeyPath: './code-signing.pem', + publicKeyPath: './code-signing.pem.pub', + nativeProjectPaths: { + ios: './ios/MyApp/Info.plist', + android: './android/app/src/main/res/values/strings.xml', + }, + }), +]; +``` + +:::info + +The automatic embedding modifies your source files in-place. After the first build with `publicKeyPath` set, the native files will contain the public key and subsequent builds will reuse it. If you change the key pair, the plugin will update the files automatically on the next build. + +::: + +#### Standalone usage + +You can also use the `embedPublicKey` function independently of the plugin, for example in a setup script: + +```js title="setup-code-signing.js" +const { plugins } = require('@callstack/repack'); + +const result = plugins.embedPublicKey({ + publicKeyPath: './code-signing.pem.pub', + projectRoot: __dirname, +}); + +console.log('iOS:', result.ios); +console.log('Android:', result.android); +``` + +#### Manual setup + +If you prefer to add the public key manually (or if automatic detection doesn't work for your project structure), you can follow the steps below. -#### iOS +##### iOS -You need to add the public key to `ios//Info.plist` under the name `RepackPublicKey`. Add the following to your `Info.plist` and then copy the contents of `code-signing.pem.pub` and paste them inside of the `` tags: +Add the public key to `ios//Info.plist` under the name `RepackPublicKey`. Add the following to your `Info.plist` and then copy the contents of `code-signing.pem.pub` and paste them inside of the `` tags: ```xml title="Info.plist" @@ -94,9 +173,9 @@ You need to add the public key to `ios//Info.plist` under the name `Rep ``` -#### Android +##### Android -You need to add the public key to `android/app/src/main/res/values/strings.xml` under the name `RepackPublicKey`. Add the following to your `strings.xml` and then copy the contents of `code-signing.pem.pub` and paste them inside of the `` tags: +Add the public key to `android/app/src/main/res/values/strings.xml` under the name `RepackPublicKey`. Add the following to your `strings.xml` and then copy the contents of `code-signing.pem.pub` and paste them inside of the `` tags: ```xml title="strings.xml" From dda6146c9fb699adcf1c7464002070caf61bf89a Mon Sep 17 00:00:00 2001 From: bartekkrok Date: Fri, 17 Apr 2026 13:02:35 +0200 Subject: [PATCH 2/6] Add changeset file for code signing --- .changeset/codesigning-plugin-auto-pubkey.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/codesigning-plugin-auto-pubkey.md diff --git a/.changeset/codesigning-plugin-auto-pubkey.md b/.changeset/codesigning-plugin-auto-pubkey.md new file mode 100644 index 000000000..766d80515 --- /dev/null +++ b/.changeset/codesigning-plugin-auto-pubkey.md @@ -0,0 +1,5 @@ +--- +"@callstack/repack": minor +--- + +Add `publicKeyPath` and `nativeProjectPaths` options to `CodeSigningPlugin`. When `publicKeyPath` is set, the plugin automatically embeds the public key into `Info.plist` (iOS) and `strings.xml` (Android) during compilation, removing the need for manual native file setup. The `embedPublicKey` utility is also exported for standalone use. From a6948b52b3c3b35880fe83ef498c6a25b2934464 Mon Sep 17 00:00:00 2001 From: bartekkrok Date: Tue, 21 Apr 2026 10:46:47 +0200 Subject: [PATCH 3/6] defer native embedding until after validation and warn on skipped platforms --- .../CodeSigningPlugin/CodeSigningPlugin.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts b/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts index 61cdeae2a..e92e562a1 100644 --- a/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts +++ b/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts @@ -78,6 +78,11 @@ export class CodeSigningPlugin { logger.info(`Embedded public key in iOS Info.plist: ${result.ios.path}`); } else if (result.ios.error) { logger.warn(`Failed to embed public key in iOS: ${result.ios.error}`); + } else { + logger.warn( + 'Could not find iOS Info.plist. Skipping auto-embedding for iOS. ' + + 'Use nativeProjectPaths.ios or manually add the public key to Info.plist.' + ); } if (result.android.modified) { @@ -88,12 +93,10 @@ export class CodeSigningPlugin { logger.warn( `Failed to embed public key in Android: ${result.android.error}` ); - } - - if (!result.ios.modified && !result.android.modified && !result.ios.error && !result.android.error) { + } else { logger.warn( - 'No native project files found. Use nativeProjectPaths to specify custom paths ' + - 'or manually add the public key to Info.plist / strings.xml.' + 'Could not find Android strings.xml. Skipping auto-embedding for Android. ' + + 'Use nativeProjectPaths.android or manually add the public key to strings.xml.' ); } } @@ -110,8 +113,6 @@ export class CodeSigningPlugin { return; } - this.embedPublicKeyInNativeProjects(compiler); - if (typeof compiler.options.output.filename === 'function') { throw new Error( '[RepackCodeSigningPlugin] Dynamic output filename is not supported. Please use static filename instead.' @@ -123,7 +124,7 @@ export class CodeSigningPlugin { */ const TOKEN_BUFFER_SIZE = 1280; /** - * Used to denote beginning of the code-signing section of the bundle + * Used to denote the beginning of the code-signing section of the bundle * alias for "Repack Code-Signing Signature Begin" */ const BEGIN_CS_MARK = '/* RCSSB */'; @@ -133,6 +134,8 @@ export class CodeSigningPlugin { : path.resolve(compiler.context, this.config.privateKeyPath); const privateKey = fs.readFileSync(privateKeyPath); + this.embedPublicKeyInNativeProjects(compiler); + const excludedChunks = Array.isArray(this.config.excludeChunks) ? this.config.excludeChunks : [this.config.excludeChunks as RegExp]; From be8801c10396295218b51b593812123d3db9da27 Mon Sep 17 00:00:00 2001 From: bartekkrok Date: Wed, 22 Apr 2026 09:41:32 +0200 Subject: [PATCH 4/6] improve error handling and reduce nested ternaries --- .../CodeSigningPlugin/CodeSigningPlugin.ts | 58 +++++++++++++------ .../src/plugins/CodeSigningPlugin/config.ts | 8 +-- .../CodeSigningPlugin/embedPublicKey.ts | 38 ++++++++---- 3 files changed, 73 insertions(+), 31 deletions(-) diff --git a/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts b/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts index e92e562a1..4b6a7ddc4 100644 --- a/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts +++ b/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts @@ -8,6 +8,16 @@ import type { Compiler as WebpackCompiler } from 'webpack'; import { type CodeSigningPluginConfig, validateConfig } from './config.js'; import { embedPublicKey } from './embedPublicKey.js'; +function resolveProjectPath( + projectRoot: string, + configPath?: string +): string | undefined { + if (!configPath) return undefined; + return path.isAbsolute(configPath) + ? configPath + : path.resolve(projectRoot, configPath); +} + export class CodeSigningPlugin { private chunkFilenames: Set; @@ -47,9 +57,10 @@ export class CodeSigningPlugin { const logger = compiler.getInfrastructureLogger('RepackCodeSigningPlugin'); const projectRoot = compiler.context; - const publicKeyPath = path.isAbsolute(this.config.publicKeyPath) - ? this.config.publicKeyPath - : path.resolve(projectRoot, this.config.publicKeyPath); + const publicKeyPath = resolveProjectPath( + projectRoot, + this.config.publicKeyPath + )!; if (!fs.existsSync(publicKeyPath)) { logger.warn( @@ -62,18 +73,21 @@ export class CodeSigningPlugin { const result = embedPublicKey({ publicKeyPath, projectRoot, - iosInfoPlistPath: this.config.nativeProjectPaths?.ios - ? path.isAbsolute(this.config.nativeProjectPaths.ios) - ? this.config.nativeProjectPaths.ios - : path.resolve(projectRoot, this.config.nativeProjectPaths.ios) - : undefined, - androidStringsXmlPath: this.config.nativeProjectPaths?.android - ? path.isAbsolute(this.config.nativeProjectPaths.android) - ? this.config.nativeProjectPaths.android - : path.resolve(projectRoot, this.config.nativeProjectPaths.android) - : undefined, + iosInfoPlistPath: resolveProjectPath( + projectRoot, + this.config.nativeProjectPaths?.ios + ), + androidStringsXmlPath: resolveProjectPath( + projectRoot, + this.config.nativeProjectPaths?.android + ), }); + if (result.error) { + logger.warn(result.error); + return; + } + if (result.ios.modified) { logger.info(`Embedded public key in iOS Info.plist: ${result.ios.path}`); } else if (result.ios.error) { @@ -129,10 +143,20 @@ export class CodeSigningPlugin { */ const BEGIN_CS_MARK = '/* RCSSB */'; - const privateKeyPath = path.isAbsolute(this.config.privateKeyPath) - ? this.config.privateKeyPath - : path.resolve(compiler.context, this.config.privateKeyPath); - const privateKey = fs.readFileSync(privateKeyPath); + const privateKeyPath = resolveProjectPath( + compiler.context, + this.config.privateKeyPath + )!; + + let privateKey: Buffer; + try { + privateKey = fs.readFileSync(privateKeyPath); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + throw new Error( + `Failed to read private key from ${privateKeyPath}: ${message}` + ); + } this.embedPublicKeyInNativeProjects(compiler); diff --git a/packages/repack/src/plugins/CodeSigningPlugin/config.ts b/packages/repack/src/plugins/CodeSigningPlugin/config.ts index ed485d69c..9ce1e504c 100644 --- a/packages/repack/src/plugins/CodeSigningPlugin/config.ts +++ b/packages/repack/src/plugins/CodeSigningPlugin/config.ts @@ -36,7 +36,7 @@ export const optionsSchema: Schema = { type: 'object', properties: { enabled: { type: 'boolean' }, - privateKeyPath: { type: 'string' }, + privateKeyPath: { type: 'string', minLength: 1 }, excludeChunks: { anyOf: [ { @@ -56,12 +56,12 @@ export const optionsSchema: Schema = { }, ], }, - publicKeyPath: { type: 'string' }, + publicKeyPath: { type: 'string', minLength: 1 }, nativeProjectPaths: { type: 'object', properties: { - ios: { type: 'string' }, - android: { type: 'string' }, + ios: { type: 'string', minLength: 1 }, + android: { type: 'string', minLength: 1 }, }, additionalProperties: false, }, diff --git a/packages/repack/src/plugins/CodeSigningPlugin/embedPublicKey.ts b/packages/repack/src/plugins/CodeSigningPlugin/embedPublicKey.ts index caf38e16e..f6f44abab 100644 --- a/packages/repack/src/plugins/CodeSigningPlugin/embedPublicKey.ts +++ b/packages/repack/src/plugins/CodeSigningPlugin/embedPublicKey.ts @@ -13,6 +13,7 @@ export interface EmbedPublicKeyConfig { } export interface EmbedPublicKeyResult { + error?: string; ios: { modified: boolean; path?: string; error?: string }; android: { modified: boolean; path?: string; error?: string }; } @@ -22,8 +23,20 @@ export interface EmbedPublicKeyResult { * Modifies `Info.plist` (iOS) and `strings.xml` (Android) so the runtime * can verify signed bundles without manual file editing. */ -export function embedPublicKey(config: EmbedPublicKeyConfig): EmbedPublicKeyResult { - const publicKey = fs.readFileSync(config.publicKeyPath, 'utf-8').trim(); +export function embedPublicKey( + config: EmbedPublicKeyConfig +): EmbedPublicKeyResult { + let publicKey: string; + try { + publicKey = fs.readFileSync(config.publicKeyPath, 'utf-8').trim(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { + error: `Failed to read public key from ${config.publicKeyPath}: ${message}`, + ios: { modified: false }, + android: { modified: false }, + }; + } const result: EmbedPublicKeyResult = { ios: { modified: false }, @@ -31,8 +44,7 @@ export function embedPublicKey(config: EmbedPublicKeyConfig): EmbedPublicKeyResu }; const plistPath = - config.iosInfoPlistPath ?? - findIOSInfoPlistPath(config.projectRoot); + config.iosInfoPlistPath ?? findIOSInfoPlistPath(config.projectRoot); if (plistPath) { try { @@ -54,7 +66,11 @@ export function embedPublicKey(config: EmbedPublicKeyConfig): EmbedPublicKeyResu result.android = { modified: true, path: stringsXmlPath }; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); - result.android = { modified: false, path: stringsXmlPath, error: message }; + result.android = { + modified: false, + path: stringsXmlPath, + error: message, + }; } } @@ -81,7 +97,12 @@ export function findIOSInfoPlistPath(projectRoot: string): string | null { for (const entry of entries) { if (!entry.isDirectory()) continue; // Skip common non-app directories - if (entry.name === 'Pods' || entry.name === 'build' || entry.name.endsWith('.xcodeproj') || entry.name.endsWith('.xcworkspace')) { + if ( + entry.name === 'Pods' || + entry.name === 'build' || + entry.name.endsWith('.xcodeproj') || + entry.name.endsWith('.xcworkspace') + ) { continue; } const plistPath = path.join(iosDir, entry.name, 'Info.plist'); @@ -186,10 +207,7 @@ export function embedPublicKeyInStringsXml( ); } content = - content.slice(0, insertIdx) + - newEntry + - '\n' + - content.slice(insertIdx); + content.slice(0, insertIdx) + newEntry + '\n' + content.slice(insertIdx); } fs.writeFileSync(stringsXmlPath, content, 'utf-8'); From 0f1d304e9ad1e3834e964da45b6c3e722c4d932a Mon Sep 17 00:00:00 2001 From: bartekkrok Date: Tue, 28 Apr 2026 12:34:11 +0200 Subject: [PATCH 5/6] fix tests after conflict resolve --- .../repack/src/plugins/__tests__/CodeSigningPlugin.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts b/packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts index bd50caa98..fcd8c28c7 100644 --- a/packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts +++ b/packages/repack/src/plugins/__tests__/CodeSigningPlugin.test.ts @@ -389,6 +389,7 @@ describe('CodeSigningPlugin - public key embedding', () => { enabled: true, privateKeyPath: path.join(tmpDir, 'code-signing.pem'), }, + [], tmpDir ); @@ -434,6 +435,7 @@ describe('CodeSigningPlugin - public key embedding', () => { privateKeyPath: path.join(tmpDir, 'code-signing.pem'), publicKeyPath: path.join(tmpDir, 'code-signing.pem.pub'), }, + [], tmpDir ); @@ -468,6 +470,7 @@ describe('CodeSigningPlugin - public key embedding', () => { privateKeyPath: path.join(tmpDir, 'code-signing.pem'), publicKeyPath: path.join(tmpDir, 'code-signing.pem.pub'), }, + [], tmpDir ); From 4a8d9763f4196de1c38c344d250f63cf796e5538 Mon Sep 17 00:00:00 2001 From: bartekkrok Date: Tue, 28 Apr 2026 12:56:24 +0200 Subject: [PATCH 6/6] Write public key if has changed, check if public and private key path exist --- .../CodeSigningPlugin/CodeSigningPlugin.ts | 18 +++++- .../CodeSigningPlugin/embedPublicKey.ts | 57 ++++++++++++------- .../plugins/__tests__/embedPublicKey.test.ts | 40 +++++++++++++ 3 files changed, 91 insertions(+), 24 deletions(-) diff --git a/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts b/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts index ccc7527f2..c19c33810 100644 --- a/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts +++ b/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts @@ -77,7 +77,9 @@ export class CodeSigningPlugin { const publicKeyPath = resolveProjectPath( projectRoot, this.config.publicKeyPath - )!; + ); + + if (!publicKeyPath) return; if (!fs.existsSync(publicKeyPath)) { logger.warn( @@ -109,6 +111,10 @@ export class CodeSigningPlugin { logger.info(`Embedded public key in iOS Info.plist: ${result.ios.path}`); } else if (result.ios.error) { logger.warn(`Failed to embed public key in iOS: ${result.ios.error}`); + } else if (result.ios.path) { + logger.debug( + `Public key already up-to-date in iOS Info.plist: ${result.ios.path}` + ); } else { logger.warn( 'Could not find iOS Info.plist. Skipping auto-embedding for iOS. ' + @@ -124,6 +130,10 @@ export class CodeSigningPlugin { logger.warn( `Failed to embed public key in Android: ${result.android.error}` ); + } else if (result.android.path) { + logger.debug( + `Public key already up-to-date in Android strings.xml: ${result.android.path}` + ); } else { logger.warn( 'Could not find Android strings.xml. Skipping auto-embedding for Android. ' + @@ -163,7 +173,11 @@ export class CodeSigningPlugin { const privateKeyPath = resolveProjectPath( compiler.context, this.config.privateKeyPath - )!; + ); + + if (!privateKeyPath) { + throw new Error('[RepackCodeSigningPlugin] privateKeyPath is required.'); + } let privateKey: Buffer; try { diff --git a/packages/repack/src/plugins/CodeSigningPlugin/embedPublicKey.ts b/packages/repack/src/plugins/CodeSigningPlugin/embedPublicKey.ts index f6f44abab..8a5e88a28 100644 --- a/packages/repack/src/plugins/CodeSigningPlugin/embedPublicKey.ts +++ b/packages/repack/src/plugins/CodeSigningPlugin/embedPublicKey.ts @@ -48,8 +48,8 @@ export function embedPublicKey( if (plistPath) { try { - embedPublicKeyInPlist(publicKey, plistPath); - result.ios = { modified: true, path: plistPath }; + const written = embedPublicKeyInPlist(publicKey, plistPath); + result.ios = { modified: written, path: plistPath }; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); result.ios = { modified: false, path: plistPath, error: message }; @@ -62,8 +62,8 @@ export function embedPublicKey( if (stringsXmlPath) { try { - embedPublicKeyInStringsXml(publicKey, stringsXmlPath); - result.android = { modified: true, path: stringsXmlPath }; + const written = embedPublicKeyInStringsXml(publicKey, stringsXmlPath); + result.android = { modified: written, path: stringsXmlPath }; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); result.android = { @@ -133,12 +133,13 @@ export function findAndroidStringsXmlPath(projectRoot: string): string | null { /** * Embeds or updates `RepackPublicKey` in an iOS `Info.plist` file. + * Returns true if the file was written, false if it was already up-to-date. */ export function embedPublicKeyInPlist( publicKey: string, plistPath: string -): void { - let content = fs.readFileSync(plistPath, 'utf-8'); +): boolean { + const existing = fs.readFileSync(plistPath, 'utf-8'); const existingKeyPattern = /[ \t]*RepackPublicKey<\/key>\s*[\s\S]*?<\/string>/; @@ -147,34 +148,39 @@ export function embedPublicKeyInPlist( '\tRepackPublicKey\n' + `\t${escapeXml(publicKey)}`; - if (existingKeyPattern.test(content)) { - content = content.replace(existingKeyPattern, replacement); + let newContent: string; + if (existingKeyPattern.test(existing)) { + newContent = existing.replace(existingKeyPattern, replacement); } else { - const insertIdx = content.lastIndexOf(''); + const insertIdx = existing.lastIndexOf(''); if (insertIdx === -1) { throw new Error( `[CodeSigningPlugin] Could not find in ${plistPath}. ` + 'The file may not be a valid Info.plist.' ); } - content = - content.slice(0, insertIdx) + + newContent = + existing.slice(0, insertIdx) + replacement + '\n' + - content.slice(insertIdx); + existing.slice(insertIdx); } - fs.writeFileSync(plistPath, content, 'utf-8'); + if (newContent === existing) return false; + + fs.writeFileSync(plistPath, newContent, 'utf-8'); + return true; } /** * Embeds or updates `RepackPublicKey` in an Android `strings.xml` file. * Creates the file if it does not exist. + * Returns true if the file was written, false if it was already up-to-date. */ export function embedPublicKeyInStringsXml( publicKey: string, stringsXmlPath: string -): void { +): boolean { const escapedKey = escapeXml(publicKey); const newEntry = ` ${escapedKey}`; @@ -188,29 +194,36 @@ export function embedPublicKeyInStringsXml( '\n' + '\n'; fs.writeFileSync(stringsXmlPath, content, 'utf-8'); - return; + return true; } - let content = fs.readFileSync(stringsXmlPath, 'utf-8'); + const existing = fs.readFileSync(stringsXmlPath, 'utf-8'); const existingPattern = /[ \t]*]*>[\s\S]*?<\/string>/; - if (existingPattern.test(content)) { - content = content.replace(existingPattern, newEntry); + let newContent: string; + if (existingPattern.test(existing)) { + newContent = existing.replace(existingPattern, newEntry); } else { - const insertIdx = content.lastIndexOf(''); + const insertIdx = existing.lastIndexOf(''); if (insertIdx === -1) { throw new Error( `[CodeSigningPlugin] Could not find in ${stringsXmlPath}. ` + 'The file may not be a valid strings.xml.' ); } - content = - content.slice(0, insertIdx) + newEntry + '\n' + content.slice(insertIdx); + newContent = + existing.slice(0, insertIdx) + + newEntry + + '\n' + + existing.slice(insertIdx); } - fs.writeFileSync(stringsXmlPath, content, 'utf-8'); + if (newContent === existing) return false; + + fs.writeFileSync(stringsXmlPath, newContent, 'utf-8'); + return true; } function escapeXml(str: string): string { diff --git a/packages/repack/src/plugins/__tests__/embedPublicKey.test.ts b/packages/repack/src/plugins/__tests__/embedPublicKey.test.ts index 19be195d5..f8c80dce4 100644 --- a/packages/repack/src/plugins/__tests__/embedPublicKey.test.ts +++ b/packages/repack/src/plugins/__tests__/embedPublicKey.test.ts @@ -80,6 +80,27 @@ describe('embedPublicKeyInPlist', () => { expect(result).toContain('CFBundleName'); }); + it('returns false and skips write when key is already up-to-date', () => { + const plistPath = path.join(tmpDir, 'Info.plist'); + fs.writeFileSync( + plistPath, + ` + + + +` + ); + + embedPublicKeyInPlist(SAMPLE_PUBLIC_KEY, plistPath); + const mtimeBefore = fs.statSync(plistPath).mtimeMs; + + const written = embedPublicKeyInPlist(SAMPLE_PUBLIC_KEY, plistPath); + const mtimeAfter = fs.statSync(plistPath).mtimeMs; + + expect(written).toBe(false); + expect(mtimeAfter).toBe(mtimeBefore); + }); + it('throws when plist has no tag', () => { const plistPath = path.join(tmpDir, 'Info.plist'); fs.writeFileSync(plistPath, ''); @@ -170,6 +191,25 @@ describe('embedPublicKeyInStringsXml', () => { expect(result).toContain(''); }); + it('returns false and skips write when key is already up-to-date', () => { + const xmlPath = path.join(tmpDir, 'strings.xml'); + fs.writeFileSync( + xmlPath, + ` + +` + ); + + embedPublicKeyInStringsXml(SAMPLE_PUBLIC_KEY, xmlPath); + const mtimeBefore = fs.statSync(xmlPath).mtimeMs; + + const written = embedPublicKeyInStringsXml(SAMPLE_PUBLIC_KEY, xmlPath); + const mtimeAfter = fs.statSync(xmlPath).mtimeMs; + + expect(written).toBe(false); + expect(mtimeAfter).toBe(mtimeBefore); + }); + it('throws when strings.xml has no tag', () => { const xmlPath = path.join(tmpDir, 'strings.xml'); fs.writeFileSync(xmlPath, 'content');