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. diff --git a/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts b/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts index 100c757f2..4b6a7ddc4 100644 --- a/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts +++ b/packages/repack/src/plugins/CodeSigningPlugin/CodeSigningPlugin.ts @@ -6,6 +6,17 @@ 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'; + +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; @@ -26,7 +37,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 +49,72 @@ export class CodeSigningPlugin { }); } + private embedPublicKeyInNativeProjects(compiler: RspackCompiler) { + if (!this.config.publicKeyPath) { + return; + } + + const logger = compiler.getInfrastructureLogger('RepackCodeSigningPlugin'); + const projectRoot = compiler.context; + + const publicKeyPath = resolveProjectPath( + 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: 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) { + 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) { + 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}` + ); + } else { + logger.warn( + 'Could not find Android strings.xml. Skipping auto-embedding for Android. ' + + 'Use nativeProjectPaths.android or manually add the public key to strings.xml.' + ); + } + } + apply(compiler: RspackCompiler): void; apply(compiler: WebpackCompiler): void; @@ -62,15 +138,27 @@ 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 */'; - 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); const excludedChunks = Array.isArray(this.config.excludeChunks) ? this.config.excludeChunks diff --git a/packages/repack/src/plugins/CodeSigningPlugin/config.ts b/packages/repack/src/plugins/CodeSigningPlugin/config.ts index 38a5e0afa..9ce1e504c 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]; @@ -18,7 +36,7 @@ export const optionsSchema: Schema = { type: 'object', properties: { enabled: { type: 'boolean' }, - privateKeyPath: { type: 'string' }, + privateKeyPath: { type: 'string', minLength: 1 }, excludeChunks: { anyOf: [ { @@ -38,6 +56,15 @@ export const optionsSchema: Schema = { }, ], }, + publicKeyPath: { type: 'string', minLength: 1 }, + nativeProjectPaths: { + type: 'object', + properties: { + ios: { type: 'string', minLength: 1 }, + android: { type: 'string', minLength: 1 }, + }, + 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..f6f44abab --- /dev/null +++ b/packages/repack/src/plugins/CodeSigningPlugin/embedPublicKey.ts @@ -0,0 +1,218 @@ +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 { + error?: string; + 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 { + 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 }, + 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"