Skip to content

feat!: add native AES-GCM encrypt/decrypt#55

Merged
huhuanming merged 6 commits into
mainfrom
feat/aes-crypto-gcm-support
May 13, 2026
Merged

feat!: add native AES-GCM encrypt/decrypt#55
huhuanming merged 6 commits into
mainfrom
feat/aes-crypto-gcm-support

Conversation

@sidmorizon
Copy link
Copy Markdown
Contributor

@sidmorizon sidmorizon commented May 13, 2026

Summary

Add native AES-GCM (AES-256-GCM) support to @onekeyfe/react-native-aes-crypto, paired with the OneKey app v2 envelope rollout (1K_ENC_V2) that lands in app-monorepo. The JS side already calls these methods via a patch-package patch on react-native-aes-crypto+3.0.27.patch; this PR moves the changes upstream so the patch can be retired once a new version is published.

Surface area:

  • TurboModuleSpec: add aesGcmEncrypt(data, key, nonce, aad) and aesGcmDecrypt(ciphertextWithTag, key, nonce, aad). All four args are hex strings; return value is a hex string.
  • Android: Cipher.getInstance("AES/GCM/NoPadding") with GCMParameterSpec(128, nonce). Output is ciphertext || tag, tag is 16 bytes. updateAAD is called unconditionally (AAD is required). Decrypt rejects on tag mismatch.
  • iOS: CryptoKit.AES.GCM via a new AesCryptoGcm.swift helper, called from AesCrypto.mm through the generated AesCrypto-Swift.h. Same ciphertext || tag layout. AesCryptoGcmError adopts LocalizedError so JS rejects carry a real message.
  • Podspec: link Security + CryptoKit, set SWIFT_ENABLE_EXPLICIT_MODULES = NO to match the existing ObjC++ + Swift bridging.
  • AesCrypto.h: wrap the Objective-C++ header in #ifdef __cplusplus so the generated AesCrypto-Swift.h can import it cleanly.
  • src/index.tsx: JSDoc on aesGcmEncrypt / aesGcmDecrypt documenting the tighter-than-spec contract.

⚠️ BREAKING CHANGES

This PR also tightens the input contract on every native entry point (introduced in 75031eb, hardened further in 0add229, refined in e8ec66a). Callers that previously got silent zero-length / 0-init behaviour now receive a synchronous -1 promise rejection.

The module is consumed inside OneKey monorepo only (via patch-package), so no external npm consumer is affected. Bumping to a new major version when this is released upstream — feat!: per conventional commits.

Newly enforced contracts:

Method New rejection conditions
encrypt, decrypt empty data/ciphertext, key, iv, algorithm
aesGcmEncrypt empty key, nonce, aad; nonce length != 12 bytes. data is allowed to be empty (yields the bare 16-byte auth tag, matching CryptoKit).
aesGcmDecrypt empty key, nonce, aad; nonce length != 12 bytes; decoded ciphertextWithTag < 16 bytes (auth-tag length). Empty ciphertextWithTag is caught by the same < 16 guard.
pbkdf2 empty password, salt, algorithm; non-finite / non-integer / non-positive / > INT_MAX cost or length
hmac256, hmac512 empty data, key
sha1, sha256, sha512 empty text
randomKey non-finite / non-integer / non-positive / > INT_MAX length

AAD is deliberately enforced even though AEAD allows 0-byte AAD — every consumer in our monorepo already supplies an explicit context binding (v2 envelope header bytes; fixed keyless AAD constants), so rejecting empty AAD turns "forgot to pass AAD" bugs into loud failures. See review thread for rationale.

Unified reject code

All native methods (Android + iOS, legacy + GCM, entry-validation failure + impl failure) now reject promises with code "-1". iOS legacy paths previously used per-method string codes (encrypt_fail / decrypt_fail / keygen_fail / hmac_fail / sha*_fail / uuid_fail / random_fail); those are gone. The reject message still carries the specific context.

JS callers that branched on the legacy iOS string codes need to switch to a single "-1" catch and inspect the message for details.

Validation

  • yarn typecheck
  • yarn prepare (react-native-builder-bob module + typescript targets) ✅
  • iOS: swiftc -typecheck on AesCryptoGcm.swift for both iphonesimulator and iphoneos SDKs (arm64-apple-ios15.5-simulator, arm64-apple-ios15.5) ✅, verified in the OneKey app-monorepo worktree.
  • Android: compiled successfully when consumed via patch-package in the OneKey app-monorepo build.

Test plan

  • iOS native build via pod install + Xcode build in a consumer app
  • Android native build via Gradle in a consumer app
  • Cross-check ciphertext || tag output on iOS / Android against the existing @noble/ciphers JS implementation using fixed vectors
  • Confirm decrypt rejects on tampered ciphertext / tag / AAD
  • Boundary vectors: non-12-byte nonce (0 / 11 / 13 bytes), NaN / Infinity / fractional cost/length, empty AAD, empty plaintext (must produce 16-byte tag, not reject), ciphertextWithTag < 16 bytes — all expected reject/accept results documented in PR; consumer-side AESGcmV2Test will assert them on both platforms before merge.

Follow-up

  • iOS legacy fromHex (CBC/CTR/PBKDF2/HMAC/SHA) uses strtol which silently maps "zz"0x00, accepts odd-length strings (truncates), and accepts 0x prefixes. Asymmetric vs the strict Swift dataFromHex / Android Hex.decode. Tracked separately because tightening it requires re-validating all legacy fixed-vector tests.

- Extend TurboModule spec with aesGcmEncrypt / aesGcmDecrypt that accept
  hex-encoded data, key, nonce and aad.
- Android: implement via Cipher.getInstance("AES/GCM/NoPadding") with
  GCMParameterSpec(128, nonce). Output is hex-encoded ciphertext||tag.
- iOS: implement via CryptoKit AES.GCM in a new AesCryptoGcm.swift
  helper bridged from AesCrypto.mm.
- Podspec: link Security + CryptoKit, disable Swift explicit modules to
  match the existing ObjC++ + Swift bridging setup.
- AesCrypto.h: guard Objective-C++ header with #ifdef __cplusplus so
  the generated AesCrypto-Swift.h can import it cleanly.

Encrypt accepts AAD and returns ciphertext||tag (tag length 16 bytes).
Decrypt accepts the same combined ciphertext||tag layout, verifies the
authentication tag and rejects the promise on tag mismatch.
@sidmorizon sidmorizon marked this pull request as draft May 13, 2026 03:05
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5d2d718d4c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@sidmorizon sidmorizon marked this pull request as ready for review May 13, 2026 03:10
Drop the unsafe early-returns flagged by Codex on PR #55:

- aesGcmEncryptImpl no longer short-circuits empty plaintext. CryptoKit on
  iOS still runs AES.GCM.seal and returns the 16-byte authentication tag
  for an empty plaintext; Android must do the same so the two backends
  produce the same `ciphertext || tag` shape and the JS layer can detect
  tampering on either side.
- aesGcmDecryptImpl no longer treats empty input as a successful decrypt.
  Instead it rejects any ciphertextWithTag shorter than the GCM tag
  length (16 bytes), matching the iOS `encrypted.count < 16` guard. This
  closes a path where a truncated or missing ciphertext could otherwise
  bypass authentication and silently resolve to "" on Android.

The decrypt guard throws an IllegalArgumentException, which the
JS-facing wrapper translates into a promise rejection ("-1", message).
sidmorizon added a commit that referenced this pull request May 13, 2026
Drop the unsafe early-returns flagged by Codex on PR #55:

- aesGcmEncryptImpl no longer short-circuits empty plaintext. CryptoKit on
  iOS still runs AES.GCM.seal and returns the 16-byte authentication tag
  for an empty plaintext; Android must do the same so the two backends
  produce the same `ciphertext || tag` shape and the JS layer can detect
  tampering on either side.
- aesGcmDecryptImpl no longer treats empty input as a successful decrypt.
  Instead it rejects any ciphertextWithTag shorter than the GCM tag
  length (16 bytes), matching the iOS `encrypted.count < 16` guard. This
  closes a path where a truncated or missing ciphertext could otherwise
  bypass authentication and silently resolve to "" on Android.

The decrypt guard throws an IllegalArgumentException, which the
JS-facing wrapper translates into a promise rejection ("-1", message).
sidmorizon added a commit to OneKeyHQ/app-monorepo that referenced this pull request May 13, 2026
Sync the Android-side fix landing in upstream PR
OneKeyHQ/app-modules#55: remove the unsafe empty-string early returns
in aesGcmEncryptImpl / aesGcmDecryptImpl so the Android backend stays
byte-aligned with the iOS CryptoKit path.

- Encrypt path: always runs Cipher.doFinal so an empty plaintext still
  resolves to the 16-byte authentication tag, matching CryptoKit's
  `AES.GCM.seal` behavior. Without this the JS layer would receive ""
  on Android while iOS returns a tag, breaking cross-platform decrypt.
- Decrypt path: rejects any ciphertextWithTag shorter than the GCM tag
  length (16 bytes) with IllegalArgumentException. Previously a missing
  or truncated payload could silently resolve to "" on Android while
  iOS already throws `invalidCiphertext`. This closes a path where a
  caller could bypass tag verification.

Patch hunk size adjusted from +30 to +31 lines accordingly.
sidmorizon added a commit to OneKeyHQ/app-monorepo that referenced this pull request May 13, 2026
Cover the three cases Codex flagged on
OneKeyHQ/app-modules#55 that the on-node jest suite cannot exercise
because they require the real native CryptoKit / Cipher paths:

- Empty plaintext: assert noble emits exactly the 16-byte GCM tag,
  the RNAes wrapper produces the same hex, and the native decrypt
  round-trips back to an empty buffer.
- Short ciphertext (<16 bytes): both native and noble decrypt must
  reject, so a missing or truncated tag can never bypass GCM
  authentication.
- Wrong AAD: both native and noble decrypt must reject when the AAD
  is tampered, even with otherwise-valid ciphertext.

These vectors run inside AES-GCM v2 Test and surface a clear pass /
reject in the table so the next round of device-level verification
(iOS simulator + Android emulator) can confirm parity.
Add defensive input validation at every native entry point so that
malformed callers — forgotten parameters, miswired AAD, truncated
input, zero key length — surface as an explicit promise rejection
instead of silently passing through to the cipher / digest layer.

Scope (Android Kotlin + iOS ObjC++ + iOS Swift):

- encrypt / decrypt: data | ciphertext, key, iv, algorithm must be
  non-empty.
- aesGcmEncrypt / aesGcmDecrypt: data | ciphertextWithTag, key, nonce
  AND aad must all be non-empty. We intentionally also require a
  non-empty AAD even though AEAD allows 0-byte AAD — every production
  caller already supplies an explicit context binding (v2 envelope
  header bytes; keyless fixed AAD constants) so rejecting empty AAD
  catches "forgot to pass AAD" bugs without affecting any current call
  site.
- pbkdf2: password, salt, algorithm must be non-empty; cost and length
  must be > 0.
- hmac256 / hmac512: data, key must be non-empty.
- sha1 / sha256 / sha512: text must be non-empty.
- randomKey: length must be > 0.

Implementation pattern:

- Android: shared `requireNonEmpty` / `requirePositive` helpers in the
  companion object; each `override fun` validates its inputs in the
  try block so any failure rejects the JS promise with the standard
  "-1" code and a descriptive message.
- iOS: ONEKEY_AES_REQUIRE_NON_EMPTY / _POSITIVE macros guard each
  `RCT_EXPORT_METHOD` body before any dispatch_async. They reject the
  promise synchronously with `-1` and a `<method>: <paramName> must
  not be empty` message.
- iOS Swift: matching `requireNonEmpty` helper inside AesCryptoGcm so
  the AES-GCM entry points reject empty hex even when called outside
  the ObjC++ macro guard.

The decrypt-side >= 16-byte tag check on AES-GCM (added in 2ea235e
for PR #55 Codex review) is preserved; the new empty-string guard
runs first and catches the "completely empty" case before the tag-
length check is reached.
sidmorizon added a commit that referenced this pull request May 13, 2026
Add defensive input validation at every native entry point so that
malformed callers — forgotten parameters, miswired AAD, truncated
input, zero key length — surface as an explicit promise rejection
instead of silently passing through to the cipher / digest layer.

Scope (Android Kotlin + iOS ObjC++ + iOS Swift):

- encrypt / decrypt: data | ciphertext, key, iv, algorithm must be
  non-empty.
- aesGcmEncrypt / aesGcmDecrypt: data | ciphertextWithTag, key, nonce
  AND aad must all be non-empty. We intentionally also require a
  non-empty AAD even though AEAD allows 0-byte AAD — every production
  caller already supplies an explicit context binding (v2 envelope
  header bytes; keyless fixed AAD constants) so rejecting empty AAD
  catches "forgot to pass AAD" bugs without affecting any current call
  site.
- pbkdf2: password, salt, algorithm must be non-empty; cost and length
  must be > 0.
- hmac256 / hmac512: data, key must be non-empty.
- sha1 / sha256 / sha512: text must be non-empty.
- randomKey: length must be > 0.

Implementation pattern:

- Android: shared `requireNonEmpty` / `requirePositive` helpers in the
  companion object; each `override fun` validates its inputs in the
  try block so any failure rejects the JS promise with the standard
  "-1" code and a descriptive message.
- iOS: ONEKEY_AES_REQUIRE_NON_EMPTY / _POSITIVE macros guard each
  `RCT_EXPORT_METHOD` body before any dispatch_async. They reject the
  promise synchronously with `-1` and a `<method>: <paramName> must
  not be empty` message.
- iOS Swift: matching `requireNonEmpty` helper inside AesCryptoGcm so
  the AES-GCM entry points reject empty hex even when called outside
  the ObjC++ macro guard.

The decrypt-side >= 16-byte tag check on AES-GCM (added in 2ea235e
for PR #55 Codex review) is preserved; the new empty-string guard
runs first and catches the "completely empty" case before the tag-
length check is reached.
sidmorizon added a commit to OneKeyHQ/app-monorepo that referenced this pull request May 13, 2026
Sync upstream PR OneKeyHQ/app-modules#55 commit 75031eb so the
native module rejects empty string / non-positive numeric inputs
at every entry point. AAD is included on purpose: every production
GCM call site supplies an explicit context binding (v2 envelope
header bytes; keyless fixed AAD constants), so rejecting empty
AAD catches "forgot to pass AAD" bugs without affecting any
current caller.

- patches/react-native-aes-crypto+3.0.27.patch: regenerated from
  the pristine 3.0.27 yarn cache zip + the upstream PR working
  tree to include the new Kotlin/ObjC++/Swift guards.
- CryptoGallery AES-GCM v2 Test: replace the three buggy "empty
  plaintext" vectors (the JS invoke check already rejects zero-
  length data, so those tests never reached the cipher layer)
  with three vectors that actually exercise the new contract:
  noble and native both reject empty plaintext, and native alone
  rejects empty AAD (the JS invoke check intentionally does not
  validate AAD so diagnostic tools can drive raw inputs).
@sidmorizon
Copy link
Copy Markdown
Contributor Author

Update: in addition to the empty-plaintext / short-ciphertext fix from 2ea235e, commit 75031eb expands the same defensive philosophy to every native entry point. Codex's two P2 highlighted the value of catching "empty/missing" inputs early — rather than only patch the two GCM paths that were flagged, the module now rejects empty strings and non-positive numeric arguments uniformly.

Scope of 75031eb:

  • encrypt / decrypt (AES-CBC / CTR): data | ciphertext, key, iv, algorithm must all be non-empty.
  • aesGcmEncrypt / aesGcmDecrypt: data | ciphertextWithTag, key, nonce and aad must all be non-empty. AAD is deliberately enforced even though AEAD allows 0-byte AAD — every consumer in our monorepo already supplies an explicit context binding (v2 envelope header bytes; fixed keyless AAD constants), so rejecting empty AAD turns "forgot to pass AAD" bugs into loud failures without affecting any production call site.
  • pbkdf2: password, salt, algorithm must be non-empty; cost and length must be > 0.
  • hmac256 / hmac512: data, key non-empty.
  • sha1 / sha256 / sha512: text non-empty.
  • randomKey: length > 0.

All three layers share the same contract:

  • Android Kotlin: requireNonEmpty / requirePositive helpers in AesCryptoModule.companion, called at every override's try block; promise rejects with -1 and a message like aesGcmEncrypt: aad must not be empty.
  • iOS ObjC++: ONEKEY_AES_REQUIRE_NON_EMPTY / _POSITIVE macros guard the body of each RCT_EXPORT_METHOD before any dispatch_async; rejection happens synchronously with the same -1 + message contract.
  • iOS Swift: a matching requireNonEmpty helper inside AesCryptoGcm so the AES-GCM entry points reject empty hex even when invoked outside the ObjC++ macro guard.

Validation:

  • yarn typecheck
  • yarn prepare (react-native-builder-bob) ✅
  • xcrun --sdk iphonesimulator swiftc -typecheck for AesCryptoGcm.swift (arm64-apple-ios15.5-simulator) ✅
  • Patched-back into OneKey app-monorepo via patches/react-native-aes-crypto+3.0.27.patch; yarn install clean apply, yarn jest core/secret AES suites still pass (59/59).

Native runtime verification (Android Gradle + iOS xcodebuild + device-side AESGcmV2Test boundary vectors in CryptoGallery) is queued in the consumer side; we'll attach results before merge.

sidmorizon added a commit to OneKeyHQ/app-monorepo that referenced this pull request May 13, 2026
The native guard added upstream in OneKeyHQ/app-modules#55 commit
75031eb rejects empty AAD, but the JS-side noble fallback used
on desktop/web/extension did not — so a desktop client could
emit an empty-AAD GCM payload that mobile native then refuses to
decrypt. Add the same check to _aesGcmInvokeCheck so both
backends share one contract.

- packages/shared/src/appCrypto/modules/aesGcm.ts: extend
  _aesGcmInvokeCheck to also throw on zero-length aad. Audit
  confirmed every production callsite supplies a non-empty AAD
  (v2 envelope header bytes for all v2 traffic; KEYLESS_*_AAD
  constants for legacy GCM in keyless storage / cloud sync /
  mnemonic / backend share). Plumb aad through the four
  encryptByNoble / decryptByNoble / encryptByRNAes / decryptByRNAes
  call sites so the guard sees it.
- CryptoGallery AES-GCM v2 Test: add an "AES-GCM noble rejects
  empty AAD" boundary vector to match the existing native one,
  so the table now shows the contract is symmetrical across
  backends.

Existing core/secret AES-GCM jest tests (aes256.test.ts +
secret-aes256.test.ts) still pass (59/59) — every test already
supplies an explicit aad in mode=gcm calls. The unrelated
snapshot drift in secret.test.ts predates this change and is
out of scope.
sidmorizon added a commit that referenced this pull request May 13, 2026
Drop the unsafe early-returns flagged by Codex on PR #55:

- aesGcmEncryptImpl no longer short-circuits empty plaintext. CryptoKit on
  iOS still runs AES.GCM.seal and returns the 16-byte authentication tag
  for an empty plaintext; Android must do the same so the two backends
  produce the same `ciphertext || tag` shape and the JS layer can detect
  tampering on either side.
- aesGcmDecryptImpl no longer treats empty input as a successful decrypt.
  Instead it rejects any ciphertextWithTag shorter than the GCM tag
  length (16 bytes), matching the iOS `encrypted.count < 16` guard. This
  closes a path where a truncated or missing ciphertext could otherwise
  bypass authentication and silently resolve to "" on Android.

The decrypt guard throws an IllegalArgumentException, which the
JS-facing wrapper translates into a promise rejection ("-1", message).
sidmorizon added a commit that referenced this pull request May 13, 2026
Add defensive input validation at every native entry point so that
malformed callers — forgotten parameters, miswired AAD, truncated
input, zero key length — surface as an explicit promise rejection
instead of silently passing through to the cipher / digest layer.

Scope (Android Kotlin + iOS ObjC++ + iOS Swift):

- encrypt / decrypt: data | ciphertext, key, iv, algorithm must be
  non-empty.
- aesGcmEncrypt / aesGcmDecrypt: data | ciphertextWithTag, key, nonce
  AND aad must all be non-empty. We intentionally also require a
  non-empty AAD even though AEAD allows 0-byte AAD — every production
  caller already supplies an explicit context binding (v2 envelope
  header bytes; keyless fixed AAD constants) so rejecting empty AAD
  catches "forgot to pass AAD" bugs without affecting any current call
  site.
- pbkdf2: password, salt, algorithm must be non-empty; cost and length
  must be > 0.
- hmac256 / hmac512: data, key must be non-empty.
- sha1 / sha256 / sha512: text must be non-empty.
- randomKey: length must be > 0.

Implementation pattern:

- Android: shared `requireNonEmpty` / `requirePositive` helpers in the
  companion object; each `override fun` validates its inputs in the
  try block so any failure rejects the JS promise with the standard
  "-1" code and a descriptive message.
- iOS: ONEKEY_AES_REQUIRE_NON_EMPTY / _POSITIVE macros guard each
  `RCT_EXPORT_METHOD` body before any dispatch_async. They reject the
  promise synchronously with `-1` and a `<method>: <paramName> must
  not be empty` message.
- iOS Swift: matching `requireNonEmpty` helper inside AesCryptoGcm so
  the AES-GCM entry points reject empty hex even when called outside
  the ObjC++ macro guard.

The decrypt-side >= 16-byte tag check on AES-GCM (added in 2ea235e
for PR #55 Codex review) is preserved; the new empty-string guard
runs first and catches the "completely empty" case before the tag-
length check is reached.
@sidmorizon sidmorizon force-pushed the feat/aes-crypto-gcm-support branch from 75031eb to eb95010 Compare May 13, 2026 06:24
Copy link
Copy Markdown
Contributor

@huhuanming huhuanming left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

整体 AES-GCM 实现 OK(布局/tag 处理/认证失败抛错都对),但把「新增 GCM」和「给所有 legacy 入口加严格非空校验」捆在一个 PR 里,带来了几处隐性 breaking change 和契约一致性问题。逐行评论如下,优先级标在每条开头。

Comment thread native-modules/react-native-aes-crypto/ios/AesCrypto.mm Outdated
Comment thread native-modules/react-native-aes-crypto/ios/AesCryptoGcm.swift Outdated
Comment thread native-modules/react-native-aes-crypto/ios/AesCrypto.h
Comment thread native-modules/react-native-aes-crypto/AesCrypto.podspec
Comment thread native-modules/react-native-aes-crypto/ios/AesCrypto.mm
Address review feedback on PR #55 (huhuanming + originalix) without
relaxing the strict-input contract:

- AES-GCM nonce: enforce a 12-byte (96-bit) length on both Android and
  iOS. GCMParameterSpec on Android accepts arbitrary nonce lengths,
  CryptoKit on iOS effectively does not — letting non-12-byte nonces
  through made it possible for Android to encrypt payloads that the
  iOS side could not decrypt. The bridge layer now decodes the nonce
  hex up front and rejects any length != 12 on both platforms.
- Numeric guards (pbkdf2 cost/length, randomKey length): the previous
  `<= 0` check passed NaN, Infinity, and fractional values straight
  through to the cipher/KDF layer (NaN compares false to every value;
  1.5 would silently cast to 1). The Kotlin helper and the ObjC++
  macro now reject non-finite, non-integer, non-positive, and
  > INT_MAX inputs uniformly with the existing `-1` reject code.
- AES-GCM impl: drop the dead `if (aad.isNullOrEmpty())` branch in
  AesCryptoModule.kt's encrypt/decrypt impls. The entry point already
  enforces non-empty AAD via requireNonEmpty, so the impl signature is
  tightened to `aad: String` and `updateAAD` is called unconditionally.
- AES-GCM error codes (iOS): rejects in aesGcmEncrypt / aesGcmDecrypt
  now use `"-1"` to match every other method in the module instead of
  the module-local `"aes_gcm_encrypt_fail"` / `"aes_gcm_decrypt_fail"`
  strings.
- AesCryptoGcm.swift: update the requireNonEmpty doc comment so it
  reflects the post-75031eb reality (legacy CBC/CTR also enforces
  non-empty inputs through the ObjC++ entry — the previous comment
  implied they were unaffected).
- AesCrypto.h: keep the `#ifdef __cplusplus` block, but add a comment
  explaining why the `@interface` lives inside it (the TurboModule
  base class is ObjC++-only).
- AesCrypto.podspec: comment the `SWIFT_ENABLE_EXPLICIT_MODULES = NO`
  workaround so future maintainers know it is tied to the ObjC++ +
  Swift bridging-header conflict rather than a general toolchain
  pessimisation.
sidmorizon added a commit that referenced this pull request May 13, 2026
Address review feedback on PR #55 (huhuanming + originalix) without
relaxing the strict-input contract:

- AES-GCM nonce: enforce a 12-byte (96-bit) length on both Android and
  iOS. GCMParameterSpec on Android accepts arbitrary nonce lengths,
  CryptoKit on iOS effectively does not — letting non-12-byte nonces
  through made it possible for Android to encrypt payloads that the
  iOS side could not decrypt. The bridge layer now decodes the nonce
  hex up front and rejects any length != 12 on both platforms.
- Numeric guards (pbkdf2 cost/length, randomKey length): the previous
  `<= 0` check passed NaN, Infinity, and fractional values straight
  through to the cipher/KDF layer (NaN compares false to every value;
  1.5 would silently cast to 1). The Kotlin helper and the ObjC++
  macro now reject non-finite, non-integer, non-positive, and
  > INT_MAX inputs uniformly with the existing `-1` reject code.
- AES-GCM impl: drop the dead `if (aad.isNullOrEmpty())` branch in
  AesCryptoModule.kt's encrypt/decrypt impls. The entry point already
  enforces non-empty AAD via requireNonEmpty, so the impl signature is
  tightened to `aad: String` and `updateAAD` is called unconditionally.
- AES-GCM error codes (iOS): rejects in aesGcmEncrypt / aesGcmDecrypt
  now use `"-1"` to match every other method in the module instead of
  the module-local `"aes_gcm_encrypt_fail"` / `"aes_gcm_decrypt_fail"`
  strings.
- AesCryptoGcm.swift: update the requireNonEmpty doc comment so it
  reflects the post-75031eb reality (legacy CBC/CTR also enforces
  non-empty inputs through the ObjC++ entry — the previous comment
  implied they were unaffected).
- AesCrypto.h: keep the `#ifdef __cplusplus` block, but add a comment
  explaining why the `@interface` lives inside it (the TurboModule
  base class is ObjC++-only).
- AesCrypto.podspec: comment the `SWIFT_ENABLE_EXPLICIT_MODULES = NO`
  workaround so future maintainers know it is tied to the ObjC++ +
  Swift bridging-header conflict rather than a general toolchain
  pessimisation.
Copy link
Copy Markdown
Contributor

@huhuanming huhuanming left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test

Copy link
Copy Markdown
Contributor

@huhuanming huhuanming left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test

Copy link
Copy Markdown
Contributor

@huhuanming huhuanming left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test

Copy link
Copy Markdown
Contributor

@huhuanming huhuanming left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test

Copy link
Copy Markdown
Contributor

@huhuanming huhuanming left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

拉了一遍 0add229,补几条按行的 follow-up,优先级标在每条开头。整体 GCM 实现 OK,主要是入口收紧后的契约一致性 / dead-code / iOS UX 的尾巴。

另:PR description 的 Test plan 6 个 checkbox 全部未勾。yarn typecheck / swiftc -typecheck / monorepo patch-back 已确认,但跨端 byte 一致性 vector + boundary 用例(11/13/0-byte nonce、NaN/Infinity cost、空 AAD)还没贴出来,建议合并前补一组实测。

Title 是 feat 但 body 列了 6 个方法的 breaking,按 conventional commits 应该是 feat!: —— 不影响合并,只是后续自动 changelog 会更准。

Comment thread native-modules/react-native-aes-crypto/ios/AesCryptoGcm.swift Outdated
Comment thread native-modules/react-native-aes-crypto/ios/AesCrypto.mm
Comment thread native-modules/react-native-aes-crypto/ios/AesCryptoGcm.swift
Comment thread native-modules/react-native-aes-crypto/src/index.tsx
… dead branches

Address huhuanming's second-round review (P1×2, P2×3, P3×1) on PR #55:

- GCM data/ciphertextWithTag: drop the entry-level `requireNonEmpty`
  guard for `aesGcmEncrypt(data)` and `aesGcmDecrypt(ciphertextWithTag)`
  on all three layers (Kotlin / ObjC++ macros / Swift). Empty plaintext
  is a legitimate AEAD operation (produces the 16-byte tag), and was the
  scenario commit `2f7086be3` was already trying to handle — the entry
  guard from `75031ebc` was silently nullifying that fix. `key` / `nonce`
  / `aad` remain strictly non-empty. The decrypt-side `>= 16-byte` tag
  guard is the only check that still fires for "too short" inputs.

- iOS legacy reject codes: switch every CBC/CTR/PBKDF2/HMAC/SHA/random
  rejection from the per-method string codes (`encrypt_fail`,
  `decrypt_fail`, `keygen_fail`, `hmac_fail`, `sha*_fail`, `uuid_fail`,
  `random_fail`) to the unified `"-1"`. Now matches Android (which was
  already uniform) and the GCM path; JS callers only need to handle a
  single error code across both platforms and all methods.

- Swift `AesCryptoGcmError`: implement `LocalizedError` with an
  `errorDescription` for `.invalidHex` and `.invalidCiphertext`, so that
  when the enum bridges to `NSError`, `localizedDescription` carries the
  real message instead of Cocoa's default
  "The operation couldn't be completed. (... error N.)". The JS reject
  message is now informative on both Android and iOS.

- Android `encryptImpl` / `decryptImpl` cleanup: remove the unreachable
  `if (text.isEmpty()) return null`, the `emptyIvSpec` companion, and
  the `if (hexIv == null || hexIv.isEmpty())` fallback. Entry-level
  `requireNonEmpty(iv, ...)` already covers those cases; tighten impl
  signatures to non-null `hexIv`.

- `src/index.tsx`: add JSDoc to `aesGcmEncrypt` / `aesGcmDecrypt`
  spelling out the contract (12-byte nonce, non-empty AAD, hex strings,
  `ciphertext || tag` layout, empty plaintext allowed) so consumers do
  not have to read native source to understand it.

P2 "legacy `fromHex` strtol leniency" left as follow-up — fixing it
requires broadening this PR to all CBC/CTR/PBKDF2/HMAC/SHA paths and
revalidating against existing fixed-vector tests; tracking separately.
sidmorizon added a commit that referenced this pull request May 13, 2026
… dead branches

Address huhuanming's second-round review (P1×2, P2×3, P3×1) on PR #55:

- GCM data/ciphertextWithTag: drop the entry-level `requireNonEmpty`
  guard for `aesGcmEncrypt(data)` and `aesGcmDecrypt(ciphertextWithTag)`
  on all three layers (Kotlin / ObjC++ macros / Swift). Empty plaintext
  is a legitimate AEAD operation (produces the 16-byte tag), and was the
  scenario commit `2f7086be3` was already trying to handle — the entry
  guard from `75031ebc` was silently nullifying that fix. `key` / `nonce`
  / `aad` remain strictly non-empty. The decrypt-side `>= 16-byte` tag
  guard is the only check that still fires for "too short" inputs.

- iOS legacy reject codes: switch every CBC/CTR/PBKDF2/HMAC/SHA/random
  rejection from the per-method string codes (`encrypt_fail`,
  `decrypt_fail`, `keygen_fail`, `hmac_fail`, `sha*_fail`, `uuid_fail`,
  `random_fail`) to the unified `"-1"`. Now matches Android (which was
  already uniform) and the GCM path; JS callers only need to handle a
  single error code across both platforms and all methods.

- Swift `AesCryptoGcmError`: implement `LocalizedError` with an
  `errorDescription` for `.invalidHex` and `.invalidCiphertext`, so that
  when the enum bridges to `NSError`, `localizedDescription` carries the
  real message instead of Cocoa's default
  "The operation couldn't be completed. (... error N.)". The JS reject
  message is now informative on both Android and iOS.

- Android `encryptImpl` / `decryptImpl` cleanup: remove the unreachable
  `if (text.isEmpty()) return null`, the `emptyIvSpec` companion, and
  the `if (hexIv == null || hexIv.isEmpty())` fallback. Entry-level
  `requireNonEmpty(iv, ...)` already covers those cases; tighten impl
  signatures to non-null `hexIv`.

- `src/index.tsx`: add JSDoc to `aesGcmEncrypt` / `aesGcmDecrypt`
  spelling out the contract (12-byte nonce, non-empty AAD, hex strings,
  `ciphertext || tag` layout, empty plaintext allowed) so consumers do
  not have to read native source to understand it.

P2 "legacy `fromHex` strtol leniency" left as follow-up — fixing it
requires broadening this PR to all CBC/CTR/PBKDF2/HMAC/SHA paths and
revalidating against existing fixed-vector tests; tracking separately.
@sidmorizon sidmorizon changed the title feat(aes-crypto): add native AES-GCM encrypt/decrypt feat!: add native AES-GCM encrypt/decrypt May 13, 2026
@sidmorizon
Copy link
Copy Markdown
Contributor Author

第二轮 review 已处理,新 HEAD e8ec66a7(GPG-signed,fast-forward 上去的):

inline 回复(每条具体在原 thread 里):

  • P1 Swift localizedDescription → 让 AesCryptoGcmError 实现 LocalizedError
  • P1 空 plaintext vs 2f7086b采纳 Option 2,GCM 入口拿掉 requireNonEmpty(data) / requireNonEmpty(ciphertextWithTag),key/nonce/aad 仍严格;decrypt 侧用 < 16-byte guard 兜底 truncated 输入
  • P2 iOS legacy 错误码 → 全量统一到 "-1"(18 处),整 module 现在单一错误码契约
  • P2 Android impl 死代码 → emptyIvSpec / if (text.isEmpty()) / if (hexIv == null) 全删,impl 签名从 nullable 收紧到 non-null
  • P2 iOS legacy fromHex strtol → 不在本 PR,作为 follow-up issue 跟,理由在原 thread
  • P3 JSDoc → src/index.tsxaesGcmEncrypt/aesGcmDecrypt 加 contract 文档

总评回应:

  • Title: 已改成 feat!: add native AES-GCM encrypt/decrypt,与 body 的 BREAKING CHANGES 表格对齐 → conventional-changelog 后续生成会正确分类。
  • Test plan boundary vectors(11/13/0-byte nonce、NaN/Infinity cost、空 AAD、空明文)→ PR body Test plan 章节补了一条专门说明:这组实测放在 OneKey app-monorepo 的 AESGcmV2Test 里跑(消费 patch-package 后的 native module),merge 前会贴 vector 结果。

BREAKING CHANGES 表格已同步更新:

  • aesGcmEncrypt: data 允许空(给 16-byte tag,与 CryptoKit 对齐)
  • aesGcmDecrypt: ciphertextWithTag< 16-byte guard 替代 requireNonEmpty,语义一致
  • iOS legacy 错误码:从 9 种字符串码统一到 "-1",JS 侧需要把按字符串码分支的 catch 换成单 "-1" 加 message 解析

请复审。

@sidmorizon sidmorizon force-pushed the feat/aes-crypto-gcm-support branch from e8ec66a to 4822ed4 Compare May 13, 2026 07:55
@sidmorizon
Copy link
Copy Markdown
Contributor Author

已 rebase 到 origin/main(6b4cfae #54 background-thread 合入后)解掉 merge conflict:

  • main 上的 feat(background-thread): add restart(mode, reason) and fix iOS reload crash #54 跑了一次 yarn version:patch,把所有 28 个 package bump 到 3.0.33,与本 PR 上一次 bump (3.0.32) 在所有 package.json 都冲突。
  • 处理:用 git rebase --onto 0add2297 92540369 drop 旧的 3.0.32 bump commit,再 rebase 5 个 code commits 到 origin/main,然后 yarn version:patch:update + :apply 重新 bump 到 3.0.34
  • 全部 6 个新 SHA 都 GPG-signed verified:
    • 4bc5d6f3 feat: add native AES-GCM encrypt/decrypt
    • 38bacb9e fix: preserve GCM auth tag for empty payloads (Android)
    • ac5ffa93 feat: reject empty string and non-positive numeric inputs
    • f8d17936 fix: enforce 12-byte GCM nonce and tighten numeric guards
    • 3fe99f46 fix: allow empty GCM plaintext, unify reject codes, clean dead branches
    • 4822ed43 chore(release): bump packages to 3.0.34

mergeable 状态从 CONFLICTING 恢复到 MERGEABLE,等 approval。Force-with-lease 上去的(不是裸 force),没有踩别人本地的 push。

review thread 里 0add2297 / e8ec66a7 等旧 SHA 的引用现在指向已 orphan 的 commits;GitHub UI 仍能解析它们但不再属于这条分支。

@huhuanming huhuanming merged commit 09b2f11 into main May 13, 2026
2 checks passed
// would silently truncate (1.5 -> 1) or saturate (NaN -> 0), and the
// `<= 0` guard alone would let either path slip through into cipher /
// KDF code that subsequently treats the value as a buffer size.
private fun requirePositiveInt(value: Double, method: String, paramName: String): Int {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里虽然已经挡住了 NaN / Infinity / 小数,但 <= Int.MAX_VALUE 仍然太宽。randomKey(2147483647) 会尝试分配接近 2GB 的 ByteArray,Android 侧还会抛 OutOfMemoryError 而不是 Exception,当前 catch (e: Exception) 接不住,进程可能直接崩。pbkdf2(cost, length) 也类似:超大的 cost 会把 KDF 线程拖死,超大的 length 会生成巨大的输出 buffer。

建议不要用一个通用的 INT_MAX 上限,而是按方法做业务上限:例如 randomKey 限制到实际需要的 key/material 字节数,pbkdf2.length 限制到可接受的 key bits,pbkdf2.cost 限制到可接受耗时范围。iOS 的 ONEKEY_AES_REQUIRE_POSITIVE 现在也有同样的 INT_MAX 上限问题,需要同步收紧。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants