@onekeyfe/react-native-bundle-crypto 3.0.53 → 3.0.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,20 +5,28 @@ react-native-bundle-crypto
5
5
  ## Installation
6
6
 
7
7
  ```sh
8
- npm install react-native-bundle-crypto react-native-nitro-modules
8
+ npm install @onekeyfe/react-native-bundle-crypto react-native-nitro-modules
9
9
 
10
10
  > `react-native-nitro-modules` is required as this library relies on [Nitro Modules](https://nitro.margelo.com/).
11
11
  ```
12
12
 
13
13
  ## Usage
14
14
 
15
- ```js
16
- import { ReactNativeBundleCrypto } from 'react-native-bundle-crypto';
15
+ ```ts
16
+ import { ReactNativeBundleCrypto } from '@onekeyfe/react-native-bundle-crypto';
17
17
 
18
18
  // ...
19
19
 
20
- const result = await ReactNativeBundleCrypto.hello({ message: 'World' });
21
- console.log(result); // { success: true, data: 'Hello, World!' }
20
+ const hash = await ReactNativeBundleCrypto.sha256OfFile('/absolute/path/bundle.zip');
21
+ if (hash.sha256) {
22
+ console.log('sha256:', hash.sha256);
23
+ }
24
+
25
+ const isSame = ReactNativeBundleCrypto.secureEqualHex(
26
+ '0123456789abcdef',
27
+ '0123456789abcdef'
28
+ );
29
+ console.log(isSame);
22
30
  ```
23
31
 
24
32
  ## Contributing
@@ -20,8 +20,8 @@ Pod::Spec.new do |s|
20
20
  ]
21
21
 
22
22
  # Vendored Gopenpgp framework: GPG cleartext/detached signature verification.
23
- # This is the single in-tree copy used by the bundle-crypto security module
24
- # (copied from react-native-bundle-update during P1; that module keeps its own).
23
+ # This is the single in-tree copy used by bundle-crypto and by modules that
24
+ # delegate to BundleCryptoCore instead of vendoring their own framework.
25
25
  s.vendored_frameworks = 'ios/Frameworks/Gopenpgp.xcframework'
26
26
 
27
27
  s.dependency 'React-jsi'
@@ -152,23 +152,13 @@ object BundleCryptoCore {
152
152
  // Extract cleartext and signature from the PGP signed message manually
153
153
  // (BouncyCastle's cleartext handling requires manual parsing).
154
154
  val lines = signedMessage.lines()
155
- val hashHeaderIdx = lines.indexOfFirst { it.startsWith("Hash:") }
156
- val sigStartIdx = lines.indexOfFirst { it == "-----BEGIN PGP SIGNATURE-----" }
157
- val sigEndIdx = lines.indexOfFirst { it == "-----END PGP SIGNATURE-----" }
158
-
159
- if (hashHeaderIdx < 0 || sigStartIdx < 0 || sigEndIdx < 0) {
160
- OneKeyLog.error("BundleCrypto", "Invalid PGP cleartext signed message format")
161
- return VerifyResult(false, null, "INVALID_FORMAT")
162
- }
163
-
164
- // The cleartext body is between the Hash header blank line and the PGP SIGNATURE block.
165
- val bodyStartIdx = hashHeaderIdx + 2 // skip Hash: line and the blank line after it
166
- val bodyLines = lines.subList(bodyStartIdx, sigStartIdx)
167
- // Remove trailing empty line that PGP adds.
168
- val cleartextBody = bodyLines.joinToString("\r\n").trimEnd()
169
-
170
- // The signature block.
171
- val sigBlock = lines.subList(sigStartIdx, sigEndIdx + 1).joinToString("\n")
155
+ val framed = frameCleartext(lines)
156
+ ?: run {
157
+ OneKeyLog.error("BundleCrypto", "Invalid PGP cleartext signed message format")
158
+ return VerifyResult(false, null, "INVALID_FORMAT")
159
+ }
160
+ val cleartextBody = framed.body
161
+ val sigBlock = framed.signatureBlock
172
162
 
173
163
  // Decode the signature.
174
164
  val sigInputStream = PGPUtil.getDecoderStream(sigBlock.byteInputStream())
@@ -193,11 +183,9 @@ object BundleCryptoCore {
193
183
  // Verify the signature.
194
184
  pgpSignature.init(JcaPGPContentVerifierBuilderProvider().setProvider(bcProvider), publicKey)
195
185
 
196
- // Dash-unescape the cleartext per RFC 4880 Section 7.1.
197
- val unescapedLines = cleartextBody.lines().map { line ->
198
- if (line.startsWith("- ")) line.substring(2) else line
199
- }
200
- val dataToVerify = unescapedLines.joinToString("\r\n").toByteArray(Charsets.UTF_8)
186
+ // Canonicalize the cleartext per RFC 4880 Section 7.1 (dash-unescape +
187
+ // per-line trailing-whitespace strip), matching iOS Gopenpgp.
188
+ val dataToVerify = canonicalizeCleartext(cleartextBody).toByteArray(Charsets.UTF_8)
201
189
  pgpSignature.update(dataToVerify)
202
190
 
203
191
  if (!pgpSignature.verify()) {
@@ -234,17 +222,10 @@ object BundleCryptoCore {
234
222
 
235
223
  return try {
236
224
  val lines = ascContent.lines()
237
- val hashHeaderIdx = lines.indexOfFirst { it.startsWith("Hash:") }
238
- val sigStartIdx = lines.indexOfFirst { it == "-----BEGIN PGP SIGNATURE-----" }
239
- val sigEndIdx = lines.indexOfFirst { it == "-----END PGP SIGNATURE-----" }
240
- if (hashHeaderIdx < 0 || sigStartIdx < 0 || sigEndIdx < 0) {
241
- return VerifyResult(false, null, "INVALID_FORMAT")
242
- }
243
-
244
- val bodyStartIdx = hashHeaderIdx + 2
245
- val bodyLines = lines.subList(bodyStartIdx, sigStartIdx)
246
- val cleartextBody = bodyLines.joinToString("\r\n").trimEnd()
247
- val sigBlock = lines.subList(sigStartIdx, sigEndIdx + 1).joinToString("\n")
225
+ val framed = frameCleartext(lines)
226
+ ?: return VerifyResult(false, null, "INVALID_FORMAT")
227
+ val cleartextBody = framed.body
228
+ val sigBlock = framed.signatureBlock
248
229
 
249
230
  // Verify GPG signature.
250
231
  val sigInputStream = PGPUtil.getDecoderStream(sigBlock.byteInputStream())
@@ -261,10 +242,9 @@ object BundleCryptoCore {
261
242
  ?: return VerifyResult(false, null, "PUBKEY_NOT_FOUND")
262
243
 
263
244
  pgpSignature.init(JcaPGPContentVerifierBuilderProvider().setProvider(bcProvider), publicKey)
264
- val unescapedLines = cleartextBody.lines().map { line ->
265
- if (line.startsWith("- ")) line.substring(2) else line
266
- }
267
- val dataToVerify = unescapedLines.joinToString("\r\n").toByteArray(Charsets.UTF_8)
245
+ // Canonicalize per RFC 4880 Section 7.1 (dash-unescape + per-line
246
+ // trailing-whitespace strip), matching iOS Gopenpgp.
247
+ val dataToVerify = canonicalizeCleartext(cleartextBody).toByteArray(Charsets.UTF_8)
268
248
  pgpSignature.update(dataToVerify)
269
249
  if (!pgpSignature.verify()) {
270
250
  return VerifyResult(false, null, "SIGNATURE_INVALID")
@@ -284,6 +264,55 @@ object BundleCryptoCore {
284
264
  }
285
265
  }
286
266
 
267
+ // MARK: - PGP cleartext framing + canonicalization (RFC 4880 §7.1)
268
+ // Shared by verifyGpgCleartext and verifyDetachedAsc so both forms parse and
269
+ // canonicalize identically.
270
+
271
+ private data class FramedCleartext(val body: String, val signatureBlock: String)
272
+
273
+ // Frame a PGP CLEARTEXT SIGNED MESSAGE per RFC 4880 §7: a header section
274
+ // (one-or-more "Key: value" lines such as Hash:) terminated by the FIRST blank
275
+ // line, then the cleartext body, then the ASCII-armored signature block. This
276
+ // is robust to zero, one, or multiple armor headers rather than assuming a
277
+ // single "Hash:" line. Returns null if the message is not well-formed.
278
+ private fun frameCleartext(lines: List<String>): FramedCleartext? {
279
+ val msgStartIdx = lines.indexOfFirst { it == "-----BEGIN PGP SIGNED MESSAGE-----" }
280
+ val sigStartIdx = lines.indexOfFirst { it == "-----BEGIN PGP SIGNATURE-----" }
281
+ val sigEndIdx = lines.indexOfFirst { it == "-----END PGP SIGNATURE-----" }
282
+ if (msgStartIdx < 0 || sigStartIdx < 0 || sigEndIdx < 0) return null
283
+ if (sigStartIdx <= msgStartIdx || sigEndIdx < sigStartIdx) return null
284
+
285
+ // Header lines run from just after the BEGIN line until the first blank line;
286
+ // the body starts immediately after that blank line. If there is no blank
287
+ // line before the signature, the format is invalid.
288
+ var blankIdx = -1
289
+ for (i in (msgStartIdx + 1) until sigStartIdx) {
290
+ if (lines[i].isEmpty()) { blankIdx = i; break }
291
+ }
292
+ if (blankIdx < 0) return null
293
+
294
+ val bodyStartIdx = blankIdx + 1
295
+ val bodyLines = lines.subList(bodyStartIdx, sigStartIdx)
296
+ // Remove the trailing empty line that PGP adds before the signature block.
297
+ val body = bodyLines.joinToString("\r\n").trimEnd()
298
+ val signatureBlock = lines.subList(sigStartIdx, sigEndIdx + 1).joinToString("\n")
299
+ return FramedCleartext(body, signatureBlock)
300
+ }
301
+
302
+ // Canonicalize cleartext for signature verification per RFC 4880 §7.1, matching
303
+ // iOS Gopenpgp:
304
+ // - dash-unescape: a line beginning with "- " has that prefix removed;
305
+ // - trailing whitespace is stripped from each line;
306
+ // - lines are rejoined with CRLF.
307
+ // Fail-safe: a stricter canonicalization can only reject otherwise-valid
308
+ // signatures, never accept an invalid one.
309
+ private fun canonicalizeCleartext(body: String): String {
310
+ return body.lines().joinToString("\r\n") { line ->
311
+ val unescaped = if (line.startsWith("- ")) line.substring(2) else line
312
+ unescaped.trimEnd(' ', '\t')
313
+ }
314
+ }
315
+
287
316
  // MARK: - sha256 (java MessageDigest streaming) — ported from calculateSHA256.
288
317
  // failureReason taxonomy preserved verbatim (FILE_NOT_FOUND / FILE_DISAPPEARED
289
318
  // / FILE_TRUNCATED / PERMISSION_DENIED / OOM / IO_<class> / UNEXPECTED_<class>).
@@ -384,8 +413,12 @@ object BundleCryptoCore {
384
413
  if (file.isDirectory) {
385
414
  if (!validateFilesRecursive(file, expected, jsBundleDir)) return false
386
415
  } else {
387
- if (file.name.contains("metadata.json") || file.name.contains(".DS_Store")) continue
388
- val relativePath = file.absolutePath.replace(jsBundleDir, "")
416
+ // Skip only by EXACT basename (defense-in-depth: substring match could
417
+ // let an unverified file like "evil-metadata.json" bypass hashing).
418
+ if (file.name == "metadata.json" || file.name == ".DS_Store") continue
419
+ // Strip only the leading base dir; a global replace would corrupt paths
420
+ // where the base dir name recurs deeper in the tree.
421
+ val relativePath = file.absolutePath.removePrefix(jsBundleDir)
389
422
  val expectedSHA256 = expected[relativePath]
390
423
  if (expectedSHA256 == null) {
391
424
  OneKeyLog.error("BundleCrypto", "[bundle-verify] File on disk not found in metadata: $relativePath")
@@ -426,8 +459,10 @@ object BundleCryptoCore {
426
459
  if (file.isDirectory) {
427
460
  hashFilesRecursive(file, jsBundleDir, out)
428
461
  } else {
429
- if (file.name.contains("metadata.json") || file.name.contains(".DS_Store")) continue
430
- val relativePath = file.absolutePath.replace(jsBundleDir, "")
462
+ // Skip only by EXACT basename (mirror verify loop / sibling check).
463
+ if (file.name == "metadata.json" || file.name == ".DS_Store") continue
464
+ // Strip only the leading base dir (see validateFilesRecursive).
465
+ val relativePath = file.absolutePath.removePrefix(jsBundleDir)
431
466
  val sha256 = calculateSHA256(file.absolutePath).sha256
432
467
  ?: throw Exception("HASH_FAILED:$relativePath")
433
468
  out.add(DirHash(relativePath, sha256))
@@ -356,6 +356,18 @@ public enum BundleCryptoCore {
356
356
  return result == 0
357
357
  }
358
358
 
359
+ /// Strip only the leading base directory from an absolute path, returning the
360
+ /// relative path with any leading "/" trimmed. Unlike replacingOccurrences,
361
+ /// this removes only the leading prefix so a base dir name that recurs deeper
362
+ /// in the tree cannot corrupt the relative path / metadata key.
363
+ private static func stripLeadingDir(_ fullPath: String, prefix: String) -> String {
364
+ if fullPath.hasPrefix(prefix) {
365
+ return String(fullPath.dropFirst(prefix.count))
366
+ }
367
+ // Fall back to trimming a leading separator so keys stay consistent.
368
+ return fullPath.hasPrefix("/") ? String(fullPath.dropFirst()) : fullPath
369
+ }
370
+
359
371
  /// Synchronous sha256 used internally by dir hashing — returns nil on any
360
372
  /// failure (used where only the hex/nil distinction matters).
361
373
  private static func calculateSHA256Sync(_ filePath: String) -> String? {
@@ -405,12 +417,18 @@ public enum BundleCryptoCore {
405
417
 
406
418
  guard let enumerator = fm.enumerator(atPath: dirPath) else { return false }
407
419
  while let file = enumerator.nextObject() as? String {
408
- if file.contains("metadata.json") || file.contains(".DS_Store") { continue }
420
+ // Skip only by EXACT basename (defense-in-depth: `file` is the relative
421
+ // path, so a substring match was even looser and could let an unverified
422
+ // file like "evil-metadata.json" or "sub/metadata.json.bak" bypass).
423
+ let basename = (file as NSString).lastPathComponent
424
+ if basename == "metadata.json" || basename == ".DS_Store" { continue }
409
425
  let fullPath = (dirPath as NSString).appendingPathComponent(file)
410
426
  var entryIsDir: ObjCBool = false
411
427
  if fm.fileExists(atPath: fullPath, isDirectory: &entryIsDir), entryIsDir.boolValue { continue }
412
428
 
413
- let relativePath = fullPath.replacingOccurrences(of: normalizedDir, with: "")
429
+ // Strip only the leading base dir; a global replace would corrupt paths
430
+ // where the base dir name recurs deeper in the tree.
431
+ let relativePath = Self.stripLeadingDir(fullPath, prefix: normalizedDir)
414
432
  guard let expectedSHA256 = expected[relativePath] else {
415
433
  OneKeyLog.error("BundleCrypto", "[bundle-verify] File on disk not found in metadata: \(relativePath)")
416
434
  return false
@@ -446,7 +464,9 @@ public enum BundleCryptoCore {
446
464
  var results: [DirHash] = []
447
465
  guard let enumerator = fm.enumerator(atPath: dirPath) else { return [] }
448
466
  while let file = enumerator.nextObject() as? String {
449
- if file.contains("metadata.json") || file.contains(".DS_Store") { continue }
467
+ // Skip only by EXACT basename (mirror verifyDirAgainstHashes / sibling check).
468
+ let basename = (file as NSString).lastPathComponent
469
+ if basename == "metadata.json" || basename == ".DS_Store" { continue }
450
470
  let fullPath = (dirPath as NSString).appendingPathComponent(file)
451
471
  var entryIsDir: ObjCBool = false
452
472
  if fm.fileExists(atPath: fullPath, isDirectory: &entryIsDir), entryIsDir.boolValue { continue }
@@ -454,7 +474,8 @@ public enum BundleCryptoCore {
454
474
  OneKeyLog.error("BundleCrypto", "hashDir: failed to hash \(file)")
455
475
  throw NSError(domain: "BundleCrypto", code: -1, userInfo: [NSLocalizedDescriptionKey: "HASH_FAILED"])
456
476
  }
457
- let relativePath = fullPath.replacingOccurrences(of: normalizedDir, with: "")
477
+ // Strip only the leading base dir (see verifyDirAgainstHashes).
478
+ let relativePath = Self.stripLeadingDir(fullPath, prefix: normalizedDir)
458
479
  results.append(DirHash(relativePath: relativePath, sha256: sha256))
459
480
  }
460
481
  return results
@@ -476,7 +497,10 @@ public enum BundleCryptoCore {
476
497
  return false
477
498
  }
478
499
  let resolvedPath = (fullPath as NSString).resolvingSymlinksInPath
479
- if !resolvedPath.hasPrefix(resolvedDestination) {
500
+ // Require a path-separator boundary (or exact match) so a sibling dir like
501
+ // "/Bundles/v1-evil" cannot pass the "/Bundles/v1" prefix check. Mirrors
502
+ // the tight Android canonicalPath check.
503
+ if resolvedPath != resolvedDestination && !resolvedPath.hasPrefix(resolvedDestination + "/") {
480
504
  OneKeyLog.error("BundleCrypto", "Path traversal detected in extracted bundle: \(file)")
481
505
  return false
482
506
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-bundle-crypto",
3
- "version": "3.0.53",
3
+ "version": "3.0.55",
4
4
  "description": "react-native-bundle-crypto",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",