From 852775c2481f1e6e8308c5feb39f2731402a6e17 Mon Sep 17 00:00:00 2001 From: TrustPin Date: Wed, 26 Aug 2026 18:48:11 +0200 Subject: [PATCH] MINOR Add embedded configuration URL --- CHANGELOG.md | 21 ++++++++++ README.md | 64 +++++++++++++++++++++++++++++ TrustPinReactNative.podspec | 4 +- android/build.gradle | 2 +- example-expo/package-lock.json | 3 +- example/ios/Podfile.lock | 18 ++++---- example/package-lock.json | 11 ++++- package-lock.json | 4 +- package.json | 2 +- plugin/src/__tests__/plugin.test.ts | 39 ++++++++++++++++++ plugin/src/android.ts | 15 +++++++ plugin/src/ios.ts | 54 ++++++++++++++++++++++++ plugin/src/types.ts | 26 ++++++++++++ scripts/check-pack-contents.sh | 10 ++++- 14 files changed, 255 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3d36db..ce34751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.3.0] - 2026-08-26 + +### Added + +- Embedded configuration: a signed configuration bundled with the app, used + only when no online source and no previously fetched configuration is + available, typically the app's very first start during an outage. Expo apps + set the config-plugin prop `embeddedConfigurationFile`, which copies the file + into both native projects and writes `EmbeddedConfigurationFile` / + `embedded_configuration_asset` into the generated config files; bare apps + ship the file and add the key themselves. Intended only for apps whose + bundled resources are protected by RASP; see "Embedded configuration" in the + README. + +### Changed + +- Updated iOS native SDK to 6.3.0 and Android native SDK to 6.3.0. +- A configuration the SDK has fetched and validated is now retained on the + device and remains usable after a process restart when every configuration + source is unreachable. + ## [6.2.0] - 2026-07-24 ### Changed diff --git a/README.md b/README.md index cb43ffc..c3d8ef6 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ holds no lever that disables or reconfigures pinning. - [Installation](#installation) - [Setup — Expo](#setup--expo) - [Setup — bare React Native](#setup--bare-react-native) +- [Embedded configuration](#embedded-configuration) - [Using the SDK](#using-the-sdk) - [API reference](#api-reference) - [Error handling](#error-handling) @@ -97,6 +98,7 @@ The config plugin accepts these props: | `mode` | `strict` \| `permissive` | Defaults to `strict`. | | `configurationUrl` | string | Optional. HTTPS endpoint for a self-hosted signed config. | | `logLevel` | `none` \| `error` \| `info` \| `debug` | Passed to the native init helper, so it also covers startup logging. | +| `embeddedConfigurationFile` | string | Optional. Path to a signed configuration bundled as a last-resort fallback. See [Embedded configuration](#embedded-configuration). | | `ios.configFile` | string | Path to an existing `TrustPin-Info.plist` instead of generating one. | | `android.configFile` | string | Path to an existing `trustpin.json` instead of generating one. | | `android.allowNonOemImages` | boolean | Default `false`. Allows release builds on non-OEM device OS images (real devices only, not emulators). | @@ -151,6 +153,7 @@ automatically): | public key | yes | base64-encoded verification key | | mode | no | `strict` (default, production) or `permissive` | | configuration URL | no | HTTPS URL for a self-hosted signed config | +| embedded configuration | no | `EmbeddedConfigurationFile` (plist) / `embedded_configuration_asset` (JSON). See [Embedded configuration](#embedded-configuration) | ### 2. Call the native init helper @@ -203,6 +206,67 @@ buildscript { Then `cd ios && pod install`, and rebuild the app. +## Embedded configuration + +TrustPin fetches its signed pinning configuration online and keeps the last +validated one on the device. For the one case where neither exists, the app's +**very first start while every configuration source is unreachable**, you can +ship a signed configuration inside the app as a last-resort fallback. + +Download the signed configuration for your project from the TrustPin dashboard, +then: + +**Expo**: point the plugin at it; prebuild copies it into both native projects +and adds the matching key to the generated config files: + +```json +["@trustpin/react-native", { + "organizationId": "your-org-id", + "projectId": "your-project-id", + "publicKey": "LS0tLS1CRUdJTi...", + "embeddedConfigurationFile": "./trustpin-seed.b64" +}] +``` + +It cannot be combined with `ios.configFile` / `android.configFile`: the plugin +only adds the key to files it generates. With your own config files, declare +the key yourself and ship the payload as shown below. + +**Bare React Native**: ship the file and reference it by name: + +- **iOS**: add `ios//trustpin-seed.b64` to the app target's **Copy + Bundle Resources**, then add to `TrustPin-Info.plist`: + ```xml + EmbeddedConfigurationFile + trustpin-seed.b64 + ``` +- **Android**: add `android/app/src/main/assets/trustpin-seed.b64`, then add to + `trustpin.json`: + ```json + "embedded_configuration_asset": "trustpin-seed.b64" + ``` + +### Requirements + +- **Use it only in apps protected by RASP** (runtime application + self-protection) that guards bundled resources against modification. An + unprotected app must not ship an embedded configuration. +- **The file must be the unmodified signed payload** from the dashboard. It is + verified against `publicKey` during native setup; a file that is missing, + unreadable, or fails verification fails startup with + `INVALID_PROJECT_CONFIG`. +- **Regenerate it in CI on every release**, so it is never older than the app + that ships it. Pins expire on their own schedule, and an embedded configuration + whose pins have all expired is equivalent to having no fallback. + +### Behaviour + +- It is never preferred over an online source or over a configuration the SDK + has already fetched and validated. +- It is subject to the same integrity checks as any other configuration: a + device that has already trusted a newer configuration will not accept an + older embedded one. + ## Using the SDK Pinning is already active — ordinary requests are validated with no extra code: diff --git a/TrustPinReactNative.podspec b/TrustPinReactNative.podspec index b566534..93f60eb 100644 --- a/TrustPinReactNative.podspec +++ b/TrustPinReactNative.podspec @@ -31,8 +31,8 @@ Pod::Spec.new do |s| ] s.pod_target_xcconfig = { "DEFINES_MODULE" => "YES" } - # Native SDK, locked to the 6.2.x. - s.dependency "TrustPinKit", "~> 6.2.0" + # Native SDK, locked to the 6.3.x. + s.dependency "TrustPinKit", "~> 6.3.0" # React Native core + New Architecture (TurboModule codegen) dependencies. install_modules_dependencies(s) diff --git a/android/build.gradle b/android/build.gradle index f78a8f6..c7f603d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -105,7 +105,7 @@ dependencies { // the example passes the typed `.debug`. api("cloud.trustpin:kotlin-sdk") { version { - strictly("[6.2.0, 6.3.0)") + strictly("[6.3.0, 6.4.0)") } } diff --git a/example-expo/package-lock.json b/example-expo/package-lock.json index 86aaee5..dc5b38e 100644 --- a/example-expo/package-lock.json +++ b/example-expo/package-lock.json @@ -23,7 +23,7 @@ }, "..": { "name": "@trustpin/react-native", - "version": "6.2.0-dev", + "version": "6.3.0", "license": "SEE LICENSE IN LICENSE", "devDependencies": { "@expo/config-plugins": "~57.0.6", @@ -35,6 +35,7 @@ "react": "19.2.3", "react-native": "0.85.3", "ts-jest": "^29.2.5", + "typedoc": "^0.28.20", "typescript": "^5.6.3", "typescript-eslint": "^8.18.0" }, diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index d9abc30..bd714a3 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1856,8 +1856,8 @@ PODS: - React-utils (= 0.85.3) - ReactNativeDependencies - ReactNativeDependencies (0.85.3) - - TrustPinKit (6.2.0) - - TrustPinReactNative (6.2.0-dev): + - TrustPinKit (6.3.0) + - TrustPinReactNative (6.3.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1878,7 +1878,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - ReactNativeDependencies - - TrustPinKit (~> 6.2.0) + - TrustPinKit (~> 6.3.0) - Yoga - Yoga (0.0.0) @@ -2122,7 +2122,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: 24e62c765683b8d89006a88a2c8f5cf019f0074d - hermes-engine: b7f913a6c7eadb06fbe2a9519e80b92bbb1fd12a + hermes-engine: 30cc42d16a9911849b72f5ac9af24a9df5f49904 RCTDeprecation: a4c521821fab57cbb125b36effe84d897d0dfa12 RCTRequired: 9f3a7e5645d4bc3f551593de7550bb66ab6e42bc RCTSwiftUI: 239ed2eb9e73de5a6f518810630f0c95e01c8702 @@ -2131,7 +2131,7 @@ SPEC CHECKSUMS: React: e2dc35338068bbd299c66f043ae0d7f25de8499e React-callinvoker: 28b25d21b124c26cebaea713ba7d801b9351dc48 React-Core: 02ed7d2ffb70437bdf2aba074a13078a7b0b9ff0 - React-Core-prebuilt: d1315d0975403bd506aae44147a96bfcb5feed8c + React-Core-prebuilt: e1da106deb995557515f0684b0ee594ff47f4e08 React-CoreModules: b3a5a42dadcde3b5d47b325bd912eb2ced89e146 React-cxxreact: fe8f88dda044e5905e99a00f41b7a874c3908716 React-debug: 92944dc4d89f56d640e75498266cbde557a48189 @@ -2194,11 +2194,11 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 25c9c516839be2c5e3d3344f95dc7da5f7e63fc2 ReactCodegen: c8f81e6c6f762dcf442a6203a1fb58f7dafc8014 ReactCommon: 7dfc3250793bf36cf221096ff59e1179e13eef7f - ReactNativeDependencies: 6d76144d940da9da93bcce9eb8a27c2a60659350 - TrustPinKit: 77a9ad322e91f2276edcd1b5ebbff7f8df484542 - TrustPinReactNative: 2215f3e95b7b8abd1ddbf6b9711e3888af81b921 + ReactNativeDependencies: a623b4d347714b61ec3c6f6ca2b0bf8c8605d75e + TrustPinKit: d4ccf971fd513161a0a51a08104fa9581fb4472e + TrustPinReactNative: 0a14f95033a95b49e1dd966c5540a92c451ec1cd Yoga: 77dfa8673de2874e1855002ae59c68b8be9b007b PODFILE CHECKSUM: b873e498c279153c00367a6291b174423836ab1b -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/example/package-lock.json b/example/package-lock.json index 96bfd6a..47d9926 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -41,22 +41,31 @@ }, "..": { "name": "@trustpin/react-native", - "version": "6.2.0-dev", + "version": "6.3.0", "license": "SEE LICENSE IN LICENSE", "devDependencies": { + "@expo/config-plugins": "~57.0.6", "@types/jest": "^29.5.14", + "@types/node": "^22.20.1", "@types/react": "^19.0.0", "eslint": "^9.17.0", "jest": "^29.7.0", "react": "19.2.3", "react-native": "0.85.3", "ts-jest": "^29.2.5", + "typedoc": "^0.28.20", "typescript": "^5.6.3", "typescript-eslint": "^8.18.0" }, "peerDependencies": { + "expo": "*", "react": "*", "react-native": ">=0.85.0" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } } }, "node_modules/@babel/code-frame": { diff --git a/package-lock.json b/package-lock.json index 27ce482..33641ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@trustpin/react-native", - "version": "6.2.0", + "version": "6.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@trustpin/react-native", - "version": "6.2.0", + "version": "6.3.0", "license": "SEE LICENSE IN LICENSE", "devDependencies": { "@expo/config-plugins": "~57.0.6", diff --git a/package.json b/package.json index 0dc559d..99a5da1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@trustpin/react-native", - "version": "6.2.0", + "version": "6.3.0", "description": "TrustPin SSL/TLS certificate pinning for React Native — in-handshake enforcement via the native TrustPin SDKs", "main": "./lib/index.js", "types": "./lib/index.d.ts", diff --git a/plugin/src/__tests__/plugin.test.ts b/plugin/src/__tests__/plugin.test.ts index 0a7a186..9fc82b2 100644 --- a/plugin/src/__tests__/plugin.test.ts +++ b/plugin/src/__tests__/plugin.test.ts @@ -87,6 +87,22 @@ describe('prop validation', () => { expect(() => resolveProps(undefined)).toThrow(TrustPinPluginError); }); + it('rejects an embedded configuration alongside a user-supplied config file', () => { + expect(() => + resolveProps({ + embeddedConfigurationFile: './trustpin-seed.b64', + ios: { configFile: './TrustPin-Info.plist' }, + android: { configFile: './trustpin.json' }, + }), + ).toThrow(TrustPinPluginError); + }); + + it('accepts an embedded configuration alongside inline credentials', () => { + expect(() => + resolveProps({ ...CREDENTIALS, embeddedConfigurationFile: './trustpin-seed.b64' }), + ).not.toThrow(); + }); + it('rejects invalid enum values and non-https configuration URLs', () => { expect(() => resolveProps({ ...CREDENTIALS, mode: 'loose' as 'strict' })).toThrow(/mode/); expect(() => resolveProps({ ...CREDENTIALS, logLevel: 'verbose' as 'debug' })).toThrow( @@ -113,6 +129,29 @@ describe('generated config files', () => { expect(plist).toContain('a&b<c>'); }); + it('points the plist at the embedded configuration by file name only', () => { + const plist = buildPlist({ + ...CREDENTIALS, + embeddedConfigurationFile: './config/trustpin-seed.b64', + }); + expect(plist).toContain('EmbeddedConfigurationFile'); + // The native loader resolves a resource name in the bundle, not a path. + expect(plist).toContain('trustpin-seed.b64'); + expect(plist).not.toContain('config/trustpin-seed.b64'); + }); + + it('omits the embedded key when no file is configured', () => { + expect(buildPlist({ ...CREDENTIALS })).not.toContain('EmbeddedConfigurationFile'); + expect(buildAssetJson({ ...CREDENTIALS })).not.toContain('embedded_configuration_asset'); + }); + + it('points the Android asset JSON at the embedded configuration by file name only', () => { + const json = JSON.parse( + buildAssetJson({ ...CREDENTIALS, embeddedConfigurationFile: './config/trustpin-seed.b64' }), + ); + expect(json.embedded_configuration_asset).toBe('trustpin-seed.b64'); + }); + it('writes snake_case JSON keys for Android', () => { const json = JSON.parse( buildAssetJson({ ...CREDENTIALS, configurationUrl: 'https://cdn.example.com' }), diff --git a/plugin/src/android.ts b/plugin/src/android.ts index ce6b8f9..450b911 100644 --- a/plugin/src/android.ts +++ b/plugin/src/android.ts @@ -33,6 +33,11 @@ export function buildAssetJson(props: TrustPinPluginProps): string { if (props.configurationUrl) { config.configuration_url = props.configurationUrl; } + if (props.embeddedConfigurationFile) { + // The native loader resolves this as an asset name, so only the file name + // travels into the JSON. + config.embedded_configuration_asset = path.basename(props.embeddedConfigurationFile); + } return `${JSON.stringify(config, null, 2)}\n`; } @@ -159,6 +164,16 @@ const withTrustPinAsset: ConfigPlugin = (config, props) => fs.mkdirSync(assetsDir, { recursive: true }); fs.writeFileSync(path.join(assetsDir, ASSET_FILE_NAME), contents); + + // Gradle bundles everything under assets/ into the APK, so copying the + // signed configuration here is all the native asset loader needs. + if (props.embeddedConfigurationFile) { + const source = path.resolve(projectRoot, props.embeddedConfigurationFile); + if (!fs.existsSync(source)) { + throw new TrustPinPluginError(`embeddedConfigurationFile not found: ${source}`); + } + fs.copyFileSync(source, path.join(assetsDir, path.basename(source))); + } return modConfig; }, ]); diff --git a/plugin/src/ios.ts b/plugin/src/ios.ts index d8229ae..8557c55 100644 --- a/plugin/src/ios.ts +++ b/plugin/src/ios.ts @@ -25,6 +25,11 @@ export function buildPlist(props: TrustPinPluginProps): string { if (props.configurationUrl) { entries.push(['ConfigurationURL', props.configurationUrl]); } + if (props.embeddedConfigurationFile) { + // The native loader resolves this as a resource name in the same bundle, + // so only the file name travels into the plist. + entries.push(['EmbeddedConfigurationFile', path.basename(props.embeddedConfigurationFile)]); + } const body = entries .map(([key, value]) => `\t${key}\n\t${escapeXml(value)}`) @@ -100,6 +105,53 @@ const withTrustPinPlist: ConfigPlugin = (config, props) => }, ]); +/** + * Copies the embedded configuration next to the plist. Writing the file is + * only half the job: `withEmbeddedSeedInBundle` adds it to the app target so + * the native loader can resolve it at runtime. + */ +const withEmbeddedSeed: ConfigPlugin = (config, props) => + withDangerousMod(config, [ + 'ios', + async modConfig => { + if (!props.embeddedConfigurationFile) { + return modConfig; + } + const projectRoot = modConfig.modRequest.projectRoot; + const platformRoot = modConfig.modRequest.platformProjectRoot; + const appName = IOSConfig.XcodeUtils.getProjectName(projectRoot); + const source = path.resolve(projectRoot, props.embeddedConfigurationFile); + if (!fs.existsSync(source)) { + throw new TrustPinPluginError(`embeddedConfigurationFile not found: ${source}`); + } + const destination = path.join(platformRoot, appName, path.basename(source)); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(source, destination); + return modConfig; + }, + ]); + +const withEmbeddedSeedInBundle: ConfigPlugin = (config, props) => + withXcodeProject(config, modConfig => { + if (!props.embeddedConfigurationFile) { + return modConfig; + } + const project = modConfig.modResults; + const projectName = IOSConfig.XcodeUtils.getProjectName(modConfig.modRequest.projectRoot); + const filePath = `${projectName}/${path.basename(props.embeddedConfigurationFile)}`; + + if (!project.hasFile(filePath)) { + IOSConfig.XcodeUtils.addResourceFileToGroup({ + filepath: filePath, + groupName: projectName, + project, + isBuildFile: true, + verbose: false, + }); + } + return modConfig; + }); + function readUserConfigFile(projectRoot: string, configFile: string): string { const source = path.resolve(projectRoot, configFile); if (!fs.existsSync(source)) { @@ -145,5 +197,7 @@ const withInitCall: ConfigPlugin = (config, props) => export const withTrustPinIos: ConfigPlugin = (config, props) => { let next = withTrustPinPlist(config, props); next = withPlistInBundle(next); + next = withEmbeddedSeed(next, props); + next = withEmbeddedSeedInBundle(next, props); return withInitCall(next, props); }; diff --git a/plugin/src/types.ts b/plugin/src/types.ts index ae8124c..20ef87f 100644 --- a/plugin/src/types.ts +++ b/plugin/src/types.ts @@ -16,6 +16,18 @@ export interface TrustPinPluginProps { configurationUrl?: string; /** Verbosity passed to the native init helper, before setup runs. */ logLevel?: 'none' | 'error' | 'info' | 'debug'; + /** + * Path, relative to the project root, of a signed configuration downloaded + * from the TrustPin dashboard. The plugin copies it into both native + * bundles and points the generated config files at it, so the SDK can fall + * back to it when no online source and no previously fetched configuration + * is available, typically the app's very first start during an outage. + * + * Use it only in apps protected by runtime application self-protection + * (RASP) that guards bundled resources against modification, and regenerate + * the file in CI on every release so it never goes stale. + */ + embeddedConfigurationFile?: string; ios?: { /** Path to an existing TrustPin-Info.plist, relative to the project root. */ configFile?: string; @@ -102,6 +114,20 @@ export function resolveProps(props: TrustPinPluginProps | undefined): TrustPinPl ); } + if (resolved.embeddedConfigurationFile) { + for (const platform of ['ios', 'android'] as const) { + if (resolved[platform]?.configFile) { + throw new TrustPinPluginError( + `embeddedConfigurationFile is set together with ${platform}.configFile. ` + + 'The plugin can only add the embedded-configuration key to a config ' + + 'file it generates; declare it in your own file instead ' + + '(EmbeddedConfigurationFile / embedded_configuration_asset) and ship ' + + 'the payload with the app yourself.', + ); + } + } + } + if (resolved.configurationUrl && !resolved.configurationUrl.startsWith('https://')) { throw new TrustPinPluginError( `configurationUrl must be an https URL; got "${resolved.configurationUrl}".`, diff --git a/scripts/check-pack-contents.sh b/scripts/check-pack-contents.sh index 00a5979..945240d 100755 --- a/scripts/check-pack-contents.sh +++ b/scripts/check-pack-contents.sh @@ -17,9 +17,17 @@ if [ -z "$PACK_JSON" ]; then npm pack --dry-run --json > "$PACK_JSON" 2>/dev/null fi +# `npm pack --json` reports an array of packed packages up to npm 11, and an +# object keyed by package name from npm 12 on. Accept both so the guard keeps +# working across the npm versions CI and developers actually run. FILES=$(node -e " const d = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')); - console.log(d[0].files.map(f => f.path).join('\n')); + const packed = Array.isArray(d) ? d : Object.values(d); + if (!packed[0] || !packed[0].files) { + console.error('unexpected \`npm pack --json\` output; cannot read the file list'); + process.exit(1); + } + console.log(packed[0].files.map(f => f.path).join('\n')); " "$PACK_JSON") failures=0