@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
|
-
```
|
|
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
|
|
21
|
-
|
|
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
|
|
24
|
-
#
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
238
|
-
|
|
239
|
-
val
|
|
240
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|