From 48d94430aa87ffe560b15d857ee1047049b56928 Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Tue, 21 Apr 2026 19:27:43 +0200 Subject: [PATCH 01/11] feat: add publicKey to ScriptManager locator --- .../src/modules/ScriptManager/NativeScriptManager.ts | 1 + packages/repack/src/modules/ScriptManager/Script.ts | 11 +++++++---- packages/repack/src/modules/ScriptManager/types.ts | 11 +++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/repack/src/modules/ScriptManager/NativeScriptManager.ts b/packages/repack/src/modules/ScriptManager/NativeScriptManager.ts index 6f0705ee4..a85b98802 100644 --- a/packages/repack/src/modules/ScriptManager/NativeScriptManager.ts +++ b/packages/repack/src/modules/ScriptManager/NativeScriptManager.ts @@ -23,6 +23,7 @@ export interface NormalizedScriptLocator { headers: { [key: string]: string } | undefined; body: string | undefined; verifyScriptSignature: NormalizedScriptLocatorSignatureVerificationMode; + publicKey?: string; } export interface Spec extends TurboModule { diff --git a/packages/repack/src/modules/ScriptManager/Script.ts b/packages/repack/src/modules/ScriptManager/Script.ts index 70bb66cd2..242fb9b27 100644 --- a/packages/repack/src/modules/ScriptManager/Script.ts +++ b/packages/repack/src/modules/ScriptManager/Script.ts @@ -137,6 +137,7 @@ export class Script { verifyScriptSignature: (locator.verifyScriptSignature as NormalizedScriptLocatorSignatureVerificationMode) ?? NormalizedScriptLocatorSignatureVerificationMode.OFF, + ...(locator.publicKey ? { publicKey: locator.publicKey } : {}), }, locator.cache ); @@ -170,7 +171,7 @@ export class Script { shouldUpdateCache( cachedData: Pick< NormalizedScriptLocator, - 'method' | 'url' | 'query' | 'headers' | 'body' + 'method' | 'url' | 'query' | 'headers' | 'body' | 'publicKey' > ) { if (!this.cache || !cachedData) { @@ -191,7 +192,7 @@ export class Script { shouldRefetch( cachedData: Pick< NormalizedScriptLocator, - 'method' | 'url' | 'query' | 'headers' | 'body' + 'method' | 'url' | 'query' | 'headers' | 'body' | 'publicKey' > ) { if (!this.cache) { @@ -211,7 +212,7 @@ export class Script { checkIfCacheDataOutdated( cachedData: Pick< NormalizedScriptLocator, - 'method' | 'url' | 'query' | 'headers' | 'body' + 'method' | 'url' | 'query' | 'headers' | 'body' | 'publicKey' > ) { return ( @@ -219,7 +220,8 @@ export class Script { cachedData.url !== this.locator.url || cachedData.query !== this.locator.query || !shallowEqual(cachedData.headers, this.locator.headers) || - cachedData.body !== this.locator.body + cachedData.body !== this.locator.body || + cachedData.publicKey !== this.locator.publicKey ); } @@ -235,6 +237,7 @@ export class Script { query: this.locator.query, headers: this.locator.headers, body: this.locator.body, + publicKey: this.locator.publicKey, }; } diff --git a/packages/repack/src/modules/ScriptManager/types.ts b/packages/repack/src/modules/ScriptManager/types.ts index 1e24a6794..65d7545e7 100644 --- a/packages/repack/src/modules/ScriptManager/types.ts +++ b/packages/repack/src/modules/ScriptManager/types.ts @@ -112,6 +112,17 @@ export interface ScriptLocator { */ verifyScriptSignature?: 'strict' | 'lax' | 'off'; + /** + * Public key in PEM format used to verify the script's signature. + * + * When omitted, Re.Pack falls back to the default key embedded in the host app + * under `RepackPublicKey`. + * + * This is useful when different teams or script owners sign their bundles with + * different private keys and the host app fetches the matching public key at runtime. + */ + publicKey?: string; + /** * Function called before loading or getting from the cache and after resolving the script locator. * It's an async function which should return a boolean indicating whether the script should be loaded or use default behaviour. From 95bf6aba531e771bf231782e61b2fb4b606c68a0 Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Tue, 21 Apr 2026 19:29:06 +0200 Subject: [PATCH 02/11] feat(android): support publicKey override for code signing --- .../main/java/com/callstack/repack/CodeSigningUtils.kt | 8 +++++--- .../java/com/callstack/repack/FileSystemScriptLoader.kt | 2 +- .../main/java/com/callstack/repack/RemoteScriptLoader.kt | 2 +- .../src/main/java/com/callstack/repack/ScriptConfig.kt | 3 +++ 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/repack/android/src/main/java/com/callstack/repack/CodeSigningUtils.kt b/packages/repack/android/src/main/java/com/callstack/repack/CodeSigningUtils.kt index cc2295a57..dc17782bf 100644 --- a/packages/repack/android/src/main/java/com/callstack/repack/CodeSigningUtils.kt +++ b/packages/repack/android/src/main/java/com/callstack/repack/CodeSigningUtils.kt @@ -83,15 +83,17 @@ class CodeSigningUtils { return null } - fun verifyBundle(context: Context, token: String?, fileContent: ByteArray?) { + fun verifyBundle( + context: Context, token: String?, fileContent: ByteArray?, stringPublicKey: String? + ) { if (token == null) { throw Exception("The bundle verification failed because no token for the bundle was found.") } - val stringPublicKey = getPublicKeyFromStringsIfExist(context) + val resolvedPublicKey = stringPublicKey ?: getPublicKeyFromStringsIfExist(context) ?: throw Exception("The bundle verification failed because PublicKey was not found in the bundle. Make sure you've added the PublicKey to the res/values/strings.xml under RepackPublicKey key.") - val publicKey = parsePublicKey(stringPublicKey) + val publicKey = parsePublicKey(resolvedPublicKey) ?: throw Exception("The bundle verification failed because the PublicKey is invalid.") val claims: Map = verifyAndDecodeToken(token, publicKey) diff --git a/packages/repack/android/src/main/java/com/callstack/repack/FileSystemScriptLoader.kt b/packages/repack/android/src/main/java/com/callstack/repack/FileSystemScriptLoader.kt index 70d7e6d83..44fa0b3ad 100644 --- a/packages/repack/android/src/main/java/com/callstack/repack/FileSystemScriptLoader.kt +++ b/packages/repack/android/src/main/java/com/callstack/repack/FileSystemScriptLoader.kt @@ -12,7 +12,7 @@ class FileSystemScriptLoader(private val reactContext: ReactContext, private val } if (config.verifyScriptSignature == "strict" || (config.verifyScriptSignature == "lax" && token != null)) { - CodeSigningUtils.verifyBundle(reactContext, token, bundle) + CodeSigningUtils.verifyBundle(reactContext, token, bundle, config.publicKey) } return bundle diff --git a/packages/repack/android/src/main/java/com/callstack/repack/RemoteScriptLoader.kt b/packages/repack/android/src/main/java/com/callstack/repack/RemoteScriptLoader.kt index c0ad6b34c..70c5e2b09 100644 --- a/packages/repack/android/src/main/java/com/callstack/repack/RemoteScriptLoader.kt +++ b/packages/repack/android/src/main/java/com/callstack/repack/RemoteScriptLoader.kt @@ -56,7 +56,7 @@ class RemoteScriptLoader(val reactContext: ReactContext, private val nativeLoade } ?: Pair(null, null) if (config.verifyScriptSignature == "strict" || (config.verifyScriptSignature == "lax" && token != null)) { - CodeSigningUtils.verifyBundle(reactContext, token, bundle) + CodeSigningUtils.verifyBundle(reactContext, token, bundle, config.publicKey) } if (bundle == null) { diff --git a/packages/repack/android/src/main/java/com/callstack/repack/ScriptConfig.kt b/packages/repack/android/src/main/java/com/callstack/repack/ScriptConfig.kt index 4a0983925..7fb9548a7 100644 --- a/packages/repack/android/src/main/java/com/callstack/repack/ScriptConfig.kt +++ b/packages/repack/android/src/main/java/com/callstack/repack/ScriptConfig.kt @@ -19,6 +19,7 @@ data class ScriptConfig( val timeout: Int, val headers: Headers, val verifyScriptSignature: String, + val publicKey: String?, val uniqueId: String, val sourceUrl: String ) { @@ -33,6 +34,7 @@ data class ScriptConfig( val headersMap = value.getMap("headers") val timeout = value.getInt("timeout") val verifyScriptSignature = requireNotNull(value.getString("verifyScriptSignature")) + val publicKey = value.getString("publicKey") val uniqueId = requireNotNull(value.getString("uniqueId")) val initialUrl = URL(urlString) @@ -79,6 +81,7 @@ data class ScriptConfig( timeout, headers.build(), verifyScriptSignature, + publicKey, uniqueId, sourceUrl ) From 47e8cde32d3bf4acaa1751f1877a8c92e6159f44 Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Tue, 21 Apr 2026 19:30:45 +0200 Subject: [PATCH 03/11] feat(ios): support publicKey override for code signing --- packages/repack/ios/CodeSigningUtils.swift | 4 ++-- packages/repack/ios/ScriptConfig.h | 2 ++ packages/repack/ios/ScriptConfig.mm | 5 +++++ packages/repack/ios/ScriptManager.mm | 10 ++++++++-- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/repack/ios/CodeSigningUtils.swift b/packages/repack/ios/CodeSigningUtils.swift index ef36f6f52..cc49e6379 100644 --- a/packages/repack/ios/CodeSigningUtils.swift +++ b/packages/repack/ios/CodeSigningUtils.swift @@ -80,12 +80,12 @@ public class CodeSigningUtils: NSObject { } @objc - public static func verifyBundle(token: String?, fileContent: NSData?) throws { + public static func verifyBundle(token: String?, fileContent: NSData?, publicKey: String?) throws { guard let token = token else { throw CodeSigningError.tokenNotFound } - guard let publicKey = getPublicKey() else { + guard let publicKey = publicKey ?? getPublicKey() else { throw CodeSigningError.publicKeyNotFound } diff --git a/packages/repack/ios/ScriptConfig.h b/packages/repack/ios/ScriptConfig.h index 2738a35a0..ffafbe110 100644 --- a/packages/repack/ios/ScriptConfig.h +++ b/packages/repack/ios/ScriptConfig.h @@ -19,6 +19,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly, nullable) NSDictionary *headers; @property (nonatomic, readonly) NSNumber *timeout; @property (nonatomic, readonly) NSString *verifyScriptSignature; +@property (nonatomic, readonly, nullable) NSString *publicKey; @property (nonatomic, readonly) NSString *uniqueId; @property (nonatomic, readonly) NSString *sourceUrl; @@ -39,6 +40,7 @@ NS_ASSUME_NONNULL_BEGIN withBody:(nullable NSData *)body withTimeout:(NSNumber *)timeout withVerifyScriptSignature:(NSString *)verifyScriptSignature + withPublicKey:(nullable NSString *)publicKey withUniqueId:(NSString *)uniqueId withSourceUrl:(NSString *)sourceUrl; diff --git a/packages/repack/ios/ScriptConfig.mm b/packages/repack/ios/ScriptConfig.mm index 3ad95fb91..65357ebfc 100644 --- a/packages/repack/ios/ScriptConfig.mm +++ b/packages/repack/ios/ScriptConfig.mm @@ -13,6 +13,7 @@ @implementation ScriptConfig @synthesize headers = _headers; @synthesize timeout = _timeout; @synthesize verifyScriptSignature = _verifyScriptSignature; +@synthesize publicKey = _publicKey; @synthesize uniqueId = _uniqueId; @synthesize sourceUrl = _sourceUrl; @@ -42,6 +43,7 @@ + (ScriptConfig *)fromConfig:(JS::NativeScriptManager::NormalizedScriptLocator & withBody:[config.body() dataUsingEncoding:NSUTF8StringEncoding] withTimeout:[NSNumber numberWithDouble:config.timeout()] withVerifyScriptSignature:config.verifyScriptSignature() + withPublicKey:config.publicKey() withUniqueId:config.uniqueId() withSourceUrl:sourceUrl]; } @@ -67,6 +69,7 @@ + (ScriptConfig *)fromConfig:(NSDictionary *)config withScriptId:(nonnull NSStri withBody:[config[@"body"] dataUsingEncoding:NSUTF8StringEncoding] withTimeout:config[@"timeout"] withVerifyScriptSignature:config[@"verifyScriptSignature"] + withPublicKey:config[@"publicKey"] withUniqueId:config[@"uniqueId"] withSourceUrl:sourceUrl]; } @@ -90,6 +93,7 @@ - (ScriptConfig *)initWithScript:(NSString *)scriptId withBody:(nullable NSData *)body withTimeout:(nonnull NSNumber *)timeout withVerifyScriptSignature:(NSString *)verifyScriptSignature + withPublicKey:(nullable NSString *)publicKey withUniqueId:(NSString *)uniqueId withSourceUrl:(nonnull NSString *)sourceUrl { @@ -103,6 +107,7 @@ - (ScriptConfig *)initWithScript:(NSString *)scriptId _headers = headers; _timeout = timeout; _verifyScriptSignature = verifyScriptSignature; + _publicKey = publicKey; _uniqueId = uniqueId; _sourceUrl = sourceUrl; return self; diff --git a/packages/repack/ios/ScriptManager.mm b/packages/repack/ios/ScriptManager.mm index 486400264..6f0297eef 100644 --- a/packages/repack/ios/ScriptManager.mm +++ b/packages/repack/ios/ScriptManager.mm @@ -259,7 +259,10 @@ - (void)downloadAndCache:(ScriptConfig *)config completionHandler:(void (^)(NSEr if ([config.verifyScriptSignature isEqualToString:@"strict"] || ([config.verifyScriptSignature isEqualToString:@"lax"] && token != nil)) { NSError *codeSigningError = nil; - [CodeSigningUtils verifyBundleWithToken:token fileContent:bundle error:&codeSigningError]; + [CodeSigningUtils verifyBundleWithToken:token + fileContent:bundle + publicKey:config.publicKey + error:&codeSigningError]; if (codeSigningError != nil) { callback(codeSigningError); return; @@ -315,7 +318,10 @@ - (void)executeFromFilesystem:(ScriptConfig *)config if ([config.verifyScriptSignature isEqualToString:@"strict"] || ([config.verifyScriptSignature isEqualToString:@"lax"] && token != nil)) { NSError *codeSigningError = nil; - [CodeSigningUtils verifyBundleWithToken:token fileContent:bundle error:&codeSigningError]; + [CodeSigningUtils verifyBundleWithToken:token + fileContent:bundle + publicKey:config.publicKey + error:&codeSigningError]; if (codeSigningError != nil) { reject(CodeExecutionFailure, codeSigningError.localizedDescription, nil); return; From 55332e12f9f1d7ba0e3741226191024192a1af8a Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Tue, 21 Apr 2026 19:31:34 +0200 Subject: [PATCH 04/11] test: cover publicKey resolver support --- .../__tests__/ScriptManager.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/repack/src/modules/ScriptManager/__tests__/ScriptManager.test.ts b/packages/repack/src/modules/ScriptManager/__tests__/ScriptManager.test.ts index a0b6e52a3..12db8508a 100644 --- a/packages/repack/src/modules/ScriptManager/__tests__/ScriptManager.test.ts +++ b/packages/repack/src/modules/ScriptManager/__tests__/ScriptManager.test.ts @@ -355,6 +355,36 @@ describe('ScriptManagerAPI', () => { }); }); + it('should resolve with custom public key override', async () => { + ScriptManager.shared.addResolver(async (scriptId, caller) => { + expect(caller).toEqual('main'); + + return { + url: Script.getRemoteURL(`http://domain.ext/${scriptId}`), + verifyScriptSignature: 'strict', + publicKey: + '-----BEGIN PUBLIC KEY-----\\ncustom\\n-----END PUBLIC KEY-----', + }; + }); + + const script = await ScriptManager.shared.resolveScript( + 'src_App_js', + 'main' + ); + + expect(script.locator).toEqual({ + url: 'http://domain.ext/src_App_js.chunk.bundle', + fetch: true, + absolute: false, + method: 'GET', + timeout: Script.DEFAULT_TIMEOUT, + verifyScriptSignature: 'strict', + publicKey: + '-----BEGIN PUBLIC KEY-----\\ncustom\\n-----END PUBLIC KEY-----', + uniqueId: 'main_src_App_js', + }); + }); + it('should resolve with body', async () => { const cache = new FakeCache(); ScriptManager.shared.setStorage(cache); @@ -570,6 +600,33 @@ describe('ScriptManagerAPI', () => { expect(script6.locator.fetch).toBe(true); }); + it('should refetch when public key changes', async () => { + const cache = new FakeCache(); + ScriptManager.shared.setStorage(cache); + + ScriptManager.shared.addResolver(async (scriptId) => { + return { + url: Script.getRemoteURL(`http://domain.ext/${scriptId}`), + publicKey: 'first-key', + }; + }); + + await ScriptManager.shared.loadScript('src_App_js'); + + ScriptManager.shared.removeAllResolvers(); + ScriptManager.shared.addResolver(async (scriptId) => { + return { + url: Script.getRemoteURL(`http://domain.ext/${scriptId}`), + publicKey: 'second-key', + }; + }); + + const script = await ScriptManager.shared.resolveScript('src_App_js'); + + expect(script.locator.fetch).toBe(true); + expect(script.locator.publicKey).toBe('second-key'); + }); + it('should throw an error on non-network errors occurrence in load script with retry', async () => { const cache = new FakeCache(); ScriptManager.shared.setStorage(cache); From d069dae6a78161876d0b9e2455ed2adfa5f51d00 Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Tue, 21 Apr 2026 19:40:58 +0200 Subject: [PATCH 05/11] docs: document per-script publicKey support --- .../src/latest/api/plugins/code-signing.md | 34 ++++++++++++++++++ .../src/latest/api/runtime/script-manager.md | 22 ++++++++++++ website/src/v4/docs/plugins/code-signing.md | 36 +++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/website/src/latest/api/plugins/code-signing.md b/website/src/latest/api/plugins/code-signing.md index d85264213..964282ed3 100644 --- a/website/src/latest/api/plugins/code-signing.md +++ b/website/src/latest/api/plugins/code-signing.md @@ -143,3 +143,37 @@ ScriptManager.shared.addResolver(async (scriptId, caller) => { } }); ``` + +### Use multiple public keys + +If different teams sign different bundles, the resolver can provide a script-specific public key at runtime. When `publicKey` is present, Re.Pack uses it for verification. When it is omitted, Re.Pack falls back to the key embedded in the app under `RepackPublicKey`. + +```js title="index.js" +import { ScriptManager, Federated } from "@callstack/repack/client"; + +const containers = { + MiniApp: "https://cdn.example.com/[name][ext]", +}; + +ScriptManager.shared.addResolver(async (scriptId, caller) => { + const resolveURL = Federated.createURLResolver({ containers }); + const url = resolveURL(scriptId, caller); + + if (!url) { + return; + } + + const metadata = await fetch( + `https://api.example.com/miniapps/${scriptId}/bundle-metadata` + ).then((response) => response.json()); + + return { + url, + query: { platform: Platform.OS }, + verifyScriptSignature: __DEV__ ? "off" : "strict", + publicKey: metadata.publicKey, + }; +}); +``` + +Only return public keys from a trusted backend or another authenticated source. Fetching both the bundle and its verification key from the same untrusted location defeats the integrity check. diff --git a/website/src/latest/api/runtime/script-manager.md b/website/src/latest/api/runtime/script-manager.md index cf4bb628f..4121a1fa7 100644 --- a/website/src/latest/api/runtime/script-manager.md +++ b/website/src/latest/api/runtime/script-manager.md @@ -231,6 +231,28 @@ ScriptManager.shared.addResolver(async (scriptId) => { }); ``` +### Code signing with a per-script public key + +When different teams sign remote bundles with different private keys, your resolver can provide the matching public key for each script. Re.Pack will use `publicKey` when present and fall back to the app-embedded `RepackPublicKey` only when it's omitted. + +```js +import { ScriptManager } from "@callstack/repack/client"; + +ScriptManager.shared.addResolver(async (scriptId) => { + const metadata = await fetch(`https://myapp.example/scripts/${scriptId}`).then( + (response) => response.json() + ); + + return { + url: metadata.bundleUrl, + verifyScriptSignature: "strict", + publicKey: metadata.publicKey, + }; +}); +``` + +Only use a `publicKey` value that comes from a trusted source. If both the bundle and the public key can be tampered with by the same attacker, signature verification no longer protects the download. + ### Enabling caching through AsyncStorage ```js diff --git a/website/src/v4/docs/plugins/code-signing.md b/website/src/v4/docs/plugins/code-signing.md index f693223d7..48fe1e62e 100644 --- a/website/src/v4/docs/plugins/code-signing.md +++ b/website/src/v4/docs/plugins/code-signing.md @@ -141,3 +141,39 @@ Integrity verification can be set (through `verifyScriptSignature`) to one of th | `strict` | Always verify the integrity of the bundle | | `lax` | Verify the integrity only if the signtarure is present | | `off` | Never verify the integrity of the bundle | + +### Use multiple public keys + +If different teams sign different bundles, the resolver can provide a script-specific public key at runtime. When `publicKey` is present, Re.Pack uses it for verification. When it is omitted, Re.Pack falls back to the key embedded in the app under `RepackPublicKey`. + +```js title="index.js" +import { ScriptManager, Federated } from '@callstack/repack/client'; + +const containers = { + MiniApp: 'https://cdn.example.com/[name][ext]', +}; + +ScriptManager.shared.addResolver(async (scriptId, caller) => { + const resolveURL = Federated.createURLResolver({ containers }); + const url = resolveURL(scriptId, caller); + + if (!url) { + return; + } + + const metadata = await fetch( + `https://api.example.com/miniapps/${scriptId}/bundle-metadata` + ).then((response) => response.json()); + + return { + url, + query: { + platform: Platform.OS, + }, + verifyScriptSignature: __DEV__ ? 'off' : 'strict', + publicKey: metadata.publicKey, + }; +}); +``` + +Only return public keys from a trusted backend or another authenticated source. Fetching both the bundle and its verification key from the same untrusted location defeats the integrity check. From 1516025b846f136f4d65464e5eb35c83e7f6af5f Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Tue, 21 Apr 2026 19:43:22 +0200 Subject: [PATCH 06/11] chore: add changeset --- .changeset/gold-jokes-itch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/gold-jokes-itch.md diff --git a/.changeset/gold-jokes-itch.md b/.changeset/gold-jokes-itch.md new file mode 100644 index 000000000..cb91382c5 --- /dev/null +++ b/.changeset/gold-jokes-itch.md @@ -0,0 +1,5 @@ +--- +"@callstack/repack": major +--- + +Add support for passing a per-script `publicKey` from `ScriptManager` resolvers so signed bundles can be verified with a runtime-provided public key instead of only the app-embedded `RepackPublicKey` From 68eb9b9a9cfdeab129de1f425bd3d747f39479c4 Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Wed, 22 Apr 2026 16:34:56 +0200 Subject: [PATCH 07/11] feat: validate publicKey overrides before native loading --- .../src/modules/ScriptManager/Script.ts | 15 ++++++++--- .../ScriptManager/normalizePublicKey.ts | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 packages/repack/src/modules/ScriptManager/normalizePublicKey.ts diff --git a/packages/repack/src/modules/ScriptManager/Script.ts b/packages/repack/src/modules/ScriptManager/Script.ts index 242fb9b27..6ec329446 100644 --- a/packages/repack/src/modules/ScriptManager/Script.ts +++ b/packages/repack/src/modules/ScriptManager/Script.ts @@ -4,6 +4,7 @@ import { NormalizedScriptLocatorHTTPMethod, NormalizedScriptLocatorSignatureVerificationMode, } from './NativeScriptManager.js'; +import { normalizePublicKey } from './normalizePublicKey.js'; import type { ScriptLocator } from './types.js'; /** @@ -117,6 +118,14 @@ export class Script { throw new Error('Property url as a function is not support'); } + const verifyScriptSignature = + (locator.verifyScriptSignature as NormalizedScriptLocatorSignatureVerificationMode) ?? + NormalizedScriptLocatorSignatureVerificationMode.OFF; + const publicKey = normalizePublicKey( + locator.publicKey, + verifyScriptSignature + ); + return new Script( key.scriptId, key.caller, @@ -134,10 +143,8 @@ export class Script { body, headers: Object.keys(headers).length ? headers : undefined, fetch: locator.cache === false ? true : fetch, - verifyScriptSignature: - (locator.verifyScriptSignature as NormalizedScriptLocatorSignatureVerificationMode) ?? - NormalizedScriptLocatorSignatureVerificationMode.OFF, - ...(locator.publicKey ? { publicKey: locator.publicKey } : {}), + verifyScriptSignature, + ...(publicKey ? { publicKey } : {}), }, locator.cache ); diff --git a/packages/repack/src/modules/ScriptManager/normalizePublicKey.ts b/packages/repack/src/modules/ScriptManager/normalizePublicKey.ts new file mode 100644 index 000000000..208aa0509 --- /dev/null +++ b/packages/repack/src/modules/ScriptManager/normalizePublicKey.ts @@ -0,0 +1,26 @@ +import { NormalizedScriptLocatorSignatureVerificationMode } from './NativeScriptManager.js'; + +const PUBLIC_KEY_PEM_PATTERN = + /^-----BEGIN PUBLIC KEY-----\s*[\s\S]+?\s*-----END PUBLIC KEY-----$/; + +export const INVALID_PUBLIC_KEY_ERROR = + 'Property publicKey must be a PEM-formatted public key enclosed in BEGIN/END PUBLIC KEY markers.'; + +export function normalizePublicKey( + publicKey: string | undefined, + verifyScriptSignature: NormalizedScriptLocatorSignatureVerificationMode +) { + if (!publicKey) return; + + const normalizedPublicKey = publicKey.trim(); + + if ( + verifyScriptSignature !== + NormalizedScriptLocatorSignatureVerificationMode.OFF && + !PUBLIC_KEY_PEM_PATTERN.test(normalizedPublicKey) + ) { + throw new Error(INVALID_PUBLIC_KEY_ERROR); + } + + return normalizedPublicKey; +} From 7f55b3518903474e6d681c7d08ae6bcb8ab602c0 Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Wed, 22 Apr 2026 16:35:51 +0200 Subject: [PATCH 08/11] fix(android): handle invalid public keys safely during verification --- .../java/com/callstack/repack/CodeSigningUtils.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/repack/android/src/main/java/com/callstack/repack/CodeSigningUtils.kt b/packages/repack/android/src/main/java/com/callstack/repack/CodeSigningUtils.kt index dc17782bf..373f147f2 100644 --- a/packages/repack/android/src/main/java/com/callstack/repack/CodeSigningUtils.kt +++ b/packages/repack/android/src/main/java/com/callstack/repack/CodeSigningUtils.kt @@ -45,13 +45,18 @@ class CodeSigningUtils { private fun parsePublicKey(stringPublicKey: String): PublicKey? { val formattedPublicKey = stringPublicKey.replace("-----BEGIN PUBLIC KEY-----", "") .replace("-----END PUBLIC KEY-----", "") - .replace(System.getProperty("line.separator")!!, "") + .replace("\\s".toRegex(), "") - val byteKey: ByteArray = Base64.decode(formattedPublicKey.toByteArray(), Base64.DEFAULT) - val x509Key = X509EncodedKeySpec(byteKey) - val kf = KeyFactory.getInstance("RSA") + if (formattedPublicKey.isBlank()) { + return null + } - return kf.generatePublic(x509Key) + return runCatching { + val byteKey: ByteArray = Base64.decode(formattedPublicKey.toByteArray(), Base64.DEFAULT) + val x509Key = X509EncodedKeySpec(byteKey) + val kf = KeyFactory.getInstance("RSA") + kf.generatePublic(x509Key) + }.getOrNull() } private fun verifyAndDecodeToken( From 66aa1d54dca5540e7b631daba233065e6e1fade5 Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Wed, 22 Apr 2026 16:36:43 +0200 Subject: [PATCH 09/11] fix(ios): avoid unsafe failures for invalid public keys --- packages/repack/ios/CodeSigningUtils.swift | 28 +++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/repack/ios/CodeSigningUtils.swift b/packages/repack/ios/CodeSigningUtils.swift index cc49e6379..751bf5c0c 100644 --- a/packages/repack/ios/CodeSigningUtils.swift +++ b/packages/repack/ios/CodeSigningUtils.swift @@ -38,21 +38,33 @@ public class CodeSigningUtils: NSObject { } let signatureB64 = convertBase64URLtoBase64(jwtSignature) - let signature = Signature(data: Data(base64Encoded: signatureB64)!) + guard let signatureData = Data(base64Encoded: signatureB64) else { + throw CodeSigningError.tokenInvalid + } + let signature = Signature(data: signatureData) - guard let pk = try? PublicKey(pemEncoded: publicKey) else { + guard let pk = try? PublicKey( + pemEncoded: publicKey.trimmingCharacters(in: .whitespacesAndNewlines) + ) else { throw CodeSigningError.publicKeyInvalid } // use b64-encoded header and payload for signature verification let tokenWithoutSignature = token.components(separatedBy: ".").dropLast().joined(separator: ".") - let clearMessage = try? ClearMessage(string: tokenWithoutSignature, using: .utf8) - - let isSuccesfullyVerified = try? clearMessage?.verify(with: pk, signature: signature, digestType: .sha256) + guard let clearMessage = try? ClearMessage(string: tokenWithoutSignature, using: .utf8) else { + throw CodeSigningError.tokenInvalid + } - if isSuccesfullyVerified! { - return jwt - } else { + do { + let isSuccesfullyVerified = try clearMessage.verify(with: pk, signature: signature, digestType: .sha256) + if isSuccesfullyVerified { + return jwt + } else { + throw CodeSigningError.tokenVerificationFailed + } + } catch let error as CodeSigningError { + throw error + } catch { throw CodeSigningError.tokenVerificationFailed } } From 55f6f408e8633ad92eec685c3da077f8d70bca6a Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Wed, 22 Apr 2026 16:37:11 +0200 Subject: [PATCH 10/11] test: cover publicKey validation and normalization --- .../__tests__/ScriptManager.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/repack/src/modules/ScriptManager/__tests__/ScriptManager.test.ts b/packages/repack/src/modules/ScriptManager/__tests__/ScriptManager.test.ts index 12db8508a..723ef1064 100644 --- a/packages/repack/src/modules/ScriptManager/__tests__/ScriptManager.test.ts +++ b/packages/repack/src/modules/ScriptManager/__tests__/ScriptManager.test.ts @@ -385,6 +385,42 @@ describe('ScriptManagerAPI', () => { }); }); + it('should reject malformed public key override when verification is enabled', async () => { + ScriptManager.shared.addResolver(async (scriptId) => { + return { + url: Script.getRemoteURL(`http://domain.ext/${scriptId}`), + verifyScriptSignature: 'strict', + publicKey: 'not-a-valid-pem-public-key', + }; + }); + + await expect( + ScriptManager.shared.resolveScript('src_App_js', 'main') + ).rejects.toThrow( + 'Property publicKey must be a PEM-formatted public key enclosed in BEGIN/END PUBLIC KEY markers.' + ); + }); + + it('should allow public key override with surrounding whitespace', async () => { + ScriptManager.shared.addResolver(async (scriptId) => { + return { + url: Script.getRemoteURL(`http://domain.ext/${scriptId}`), + verifyScriptSignature: 'strict', + publicKey: + '\n -----BEGIN PUBLIC KEY-----\\ncustom\\n-----END PUBLIC KEY----- \n', + }; + }); + + const script = await ScriptManager.shared.resolveScript( + 'src_App_js', + 'main' + ); + + expect(script.locator.publicKey).toBe( + '-----BEGIN PUBLIC KEY-----\\ncustom\\n-----END PUBLIC KEY-----' + ); + }); + it('should resolve with body', async () => { const cache = new FakeCache(); ScriptManager.shared.setStorage(cache); From a8b4038c53b3d9935f9a1adbbe00509f0a3ed034 Mon Sep 17 00:00:00 2001 From: Mikita Kliushun Date: Wed, 22 Apr 2026 16:37:33 +0200 Subject: [PATCH 11/11] docs: add security warning for runtime public keys --- website/src/latest/api/plugins/code-signing.md | 6 +++++- website/src/v4/docs/plugins/code-signing.md | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/website/src/latest/api/plugins/code-signing.md b/website/src/latest/api/plugins/code-signing.md index 964282ed3..9ed611392 100644 --- a/website/src/latest/api/plugins/code-signing.md +++ b/website/src/latest/api/plugins/code-signing.md @@ -176,4 +176,8 @@ ScriptManager.shared.addResolver(async (scriptId, caller) => { }); ``` -Only return public keys from a trusted backend or another authenticated source. Fetching both the bundle and its verification key from the same untrusted location defeats the integrity check. +:::danger Security warning + +Only return public keys from a **trusted, authenticated backend**. If both the bundle and its public key can be fetched from the same untrusted location, signature verification no longer protects the download and an attacker can replace both at the same time. + +::: diff --git a/website/src/v4/docs/plugins/code-signing.md b/website/src/v4/docs/plugins/code-signing.md index 48fe1e62e..c2c0988ec 100644 --- a/website/src/v4/docs/plugins/code-signing.md +++ b/website/src/v4/docs/plugins/code-signing.md @@ -176,4 +176,8 @@ ScriptManager.shared.addResolver(async (scriptId, caller) => { }); ``` -Only return public keys from a trusted backend or another authenticated source. Fetching both the bundle and its verification key from the same untrusted location defeats the integrity check. +:::danger Security warning + +Only return public keys from a **trusted, authenticated backend**. If both the bundle and its public key can be fetched from the same untrusted location, signature verification no longer protects the download and an attacker can replace both at the same time. + +:::