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` 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..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( @@ -83,15 +88,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 ) diff --git a/packages/repack/ios/CodeSigningUtils.swift b/packages/repack/ios/CodeSigningUtils.swift index ef36f6f52..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 } } @@ -80,12 +92,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; 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..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,9 +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, + verifyScriptSignature, + ...(publicKey ? { publicKey } : {}), }, locator.cache ); @@ -170,7 +178,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 +199,7 @@ export class Script { shouldRefetch( cachedData: Pick< NormalizedScriptLocator, - 'method' | 'url' | 'query' | 'headers' | 'body' + 'method' | 'url' | 'query' | 'headers' | 'body' | 'publicKey' > ) { if (!this.cache) { @@ -211,7 +219,7 @@ export class Script { checkIfCacheDataOutdated( cachedData: Pick< NormalizedScriptLocator, - 'method' | 'url' | 'query' | 'headers' | 'body' + 'method' | 'url' | 'query' | 'headers' | 'body' | 'publicKey' > ) { return ( @@ -219,7 +227,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 +244,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/__tests__/ScriptManager.test.ts b/packages/repack/src/modules/ScriptManager/__tests__/ScriptManager.test.ts index a0b6e52a3..723ef1064 100644 --- a/packages/repack/src/modules/ScriptManager/__tests__/ScriptManager.test.ts +++ b/packages/repack/src/modules/ScriptManager/__tests__/ScriptManager.test.ts @@ -355,6 +355,72 @@ 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 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); @@ -570,6 +636,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); 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; +} 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. diff --git a/website/src/latest/api/plugins/code-signing.md b/website/src/latest/api/plugins/code-signing.md index d85264213..9ed611392 100644 --- a/website/src/latest/api/plugins/code-signing.md +++ b/website/src/latest/api/plugins/code-signing.md @@ -143,3 +143,41 @@ 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, + }; +}); +``` + +:::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/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..c2c0988ec 100644 --- a/website/src/v4/docs/plugins/code-signing.md +++ b/website/src/v4/docs/plugins/code-signing.md @@ -141,3 +141,43 @@ 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, + }; +}); +``` + +:::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. + +:::