react-native-keystore-crypto-joe 0.1.0

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 ADDED
@@ -0,0 +1,71 @@
1
+ # react-native-keystore-crypto (starter)
2
+
3
+ > RSA-OAEP-SHA256 decrypt using **Android Keystore** / **iOS Keychain** with optional **biometric prompt**.
4
+ > Use this to provision a small secret (e.g., TOTP seed) sent by a server encrypted with the app's public key.
5
+
6
+ ## Install (from Git)
7
+
8
+ ```bash
9
+ yarn add git+https://github.com/yourname/react-native-keystore-crypto.git
10
+ # or
11
+ npm i git+https://github.com/yourname/react-native-keystore-crypto.git
12
+ ```
13
+
14
+ For local dev, you can `yarn add ../react-native-keystore-crypto`.
15
+
16
+ ### iOS
17
+ ```bash
18
+ cd ios && pod install
19
+ ```
20
+
21
+ ## API
22
+
23
+ ```ts
24
+ import KeystoreCrypto from 'react-native-keystore-crypto';
25
+
26
+ await KeystoreCrypto.generateKeyPair(alias: string): Promise<void>;
27
+ await KeystoreCrypto.getPublicKeyPem(alias: string): Promise<string>; // PEM SubjectPublicKeyInfo
28
+ await KeystoreCrypto.decryptRsaOaep(alias: string, ciphertextB64: string, requireBiometric?: boolean): Promise<string>; // plaintext base64
29
+ ```
30
+
31
+ ## Typical Flow
32
+
33
+ 1. `generateKeyPair(alias)` once per device/user.
34
+ 2. `getPublicKeyPem(alias)` → send to server.
35
+ 3. Server encrypts secret using **RSA-OAEP-SHA256** with the given public key → returns `ciphertext_b64`.
36
+ 4. App calls `decryptRsaOaep(alias, ciphertext_b64, true)` → gets `secret_b64` → store with `react-native-keychain` (bind to biometrics).
37
+
38
+ ## Usage Example
39
+
40
+ ```ts
41
+ import KeystoreCrypto from 'react-native-keystore-crypto';
42
+ import * as Keychain from 'react-native-keychain';
43
+ import { Buffer } from 'buffer';
44
+
45
+ const KEY_ALIAS = 'otp_keypair_user123_deviceABC';
46
+
47
+ export async function provisionAndStoreSecret(ciphertextB64FromServer: string) {
48
+ try { await KeystoreCrypto.generateKeyPair(KEY_ALIAS); } catch {}
49
+
50
+ const secretB64 = await KeystoreCrypto.decryptRsaOaep(KEY_ALIAS, ciphertextB64FromServer, true);
51
+ const secretUtf8 = Buffer.from(secretB64, 'base64').toString('utf8');
52
+
53
+ await Keychain.setGenericPassword('k', secretB64, {
54
+ service: 'otp_secret',
55
+ accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED,
56
+ accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET,
57
+ });
58
+
59
+ return { secretUtf8, secretB64 };
60
+ }
61
+ ```
62
+
63
+ ## Notes
64
+
65
+ - Android uses `RSA/ECB/OAEPWithSHA-256AndMGF1Padding` (OAEP SHA-256).
66
+ - iOS uses `SecKeyCreateDecryptedData(..., .rsaEncryptionOAEPSHA256, ...)`.
67
+ - RSA plaintext size is limited (≈190 bytes for 2048-bit + OAEP SHA-256). Use envelope encryption for larger payloads.
68
+ - iOS RSA decryption happens in **Keychain** (not Secure Enclave). To use Enclave, use EC keys (signing) rather than RSA decrypt.
69
+
70
+ ## License
71
+ MIT
@@ -0,0 +1,36 @@
1
+ buildscript {
2
+ repositories {
3
+ google()
4
+ mavenCentral()
5
+ }
6
+ dependencies {
7
+ classpath("com.android.tools.build:gradle:8.1.0")
8
+ }
9
+ }
10
+
11
+ apply plugin: 'com.android.library'
12
+
13
+ android {
14
+ compileSdkVersion 34
15
+ defaultConfig {
16
+ minSdkVersion 23
17
+ targetSdkVersion 34
18
+ consumerProguardFiles 'consumer-rules.pro'
19
+ }
20
+ sourceSets {
21
+ main {
22
+ java.srcDirs = ['src/main/java']
23
+ manifest.srcFile 'src/main/AndroidManifest.xml'
24
+ }
25
+ }
26
+ namespace "com.keystorecrypto"
27
+ }
28
+
29
+ repositories {
30
+ google()
31
+ mavenCentral()
32
+ }
33
+
34
+ dependencies {
35
+ implementation 'androidx.biometric:biometric:1.2.0-alpha05'
36
+ }
File without changes
@@ -0,0 +1,4 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
2
+ package="com.keystorecrypto">
3
+ <application/>
4
+ </manifest>
@@ -0,0 +1,138 @@
1
+ package com.keystorecrypto
2
+
3
+ import android.app.Activity
4
+ import android.os.Build
5
+ import android.security.keystore.KeyGenParameterSpec
6
+ import android.security.keystore.KeyProperties
7
+ import android.util.Base64
8
+ import androidx.biometric.BiometricPrompt
9
+ import androidx.core.content.ContextCompat
10
+ import com.facebook.react.bridge.*
11
+ import java.security.KeyPairGenerator
12
+ import java.security.KeyStore
13
+ import javax.crypto.Cipher
14
+ import javax.crypto.spec.OAEPParameterSpec
15
+ import javax.crypto.spec.PSource
16
+
17
+ class KeystoreCryptoModule(private val reactContext: ReactApplicationContext) :
18
+ ReactContextBaseJavaModule(reactContext) {
19
+
20
+ override fun getName() = "KeystoreCrypto"
21
+
22
+ @ReactMethod
23
+ fun generateKeyPair(alias: String, promise: Promise) {
24
+ try {
25
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
26
+ promise.reject("UNSUPPORTED", "Android < 6.0 không hỗ trợ AndroidKeyStore")
27
+ return
28
+ }
29
+ val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore")
30
+ val builder = KeyGenParameterSpec.Builder(
31
+ alias,
32
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
33
+ )
34
+ .setKeySize(2048)
35
+ .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
36
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
37
+ .setUserAuthenticationRequired(true)
38
+
39
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
40
+ builder.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
41
+ }
42
+
43
+ kpg.initialize(builder.build())
44
+ kpg.generateKeyPair()
45
+ promise.resolve(null)
46
+ } catch (e: Exception) {
47
+ promise.reject("GEN_KEY_ERR", e)
48
+ }
49
+ }
50
+
51
+ private fun getPrivateKey(alias: String): java.security.PrivateKey {
52
+ val ks = KeyStore.getInstance("AndroidKeyStore")
53
+ ks.load(null)
54
+ val entry = ks.getEntry(alias, null) as? KeyStore.PrivateKeyEntry
55
+ ?: throw IllegalStateException("No private key for alias=$alias")
56
+ return entry.privateKey
57
+ }
58
+
59
+ @ReactMethod
60
+ fun getPublicKeyPem(alias: String, promise: Promise) {
61
+ try {
62
+ val ks = KeyStore.getInstance("AndroidKeyStore")
63
+ ks.load(null)
64
+ val cert = ks.getCertificate(alias) ?: throw IllegalStateException("No cert for alias=$alias")
65
+ val spki = cert.publicKey.encoded
66
+ val b64 = Base64.encodeToString(spki, Base64.NO_WRAP)
67
+ val pem = "-----BEGIN PUBLIC KEY-----\n$b64\n-----END PUBLIC KEY-----"
68
+ promise.resolve(pem)
69
+ } catch (e: Exception) {
70
+ promise.reject("PUBKEY_ERR", e)
71
+ }
72
+ }
73
+
74
+ private fun newOaepCipherForDecrypt(privateKey: java.security.PrivateKey): Cipher {
75
+ val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")
76
+ val oaep = OAEPParameterSpec(
77
+ "SHA-256",
78
+ "MGF1",
79
+ java.security.spec.MGF1ParameterSpec.SHA1, // Android OAEP default MGF1 SHA-1
80
+ PSource.PSpecified.DEFAULT
81
+ )
82
+ cipher.init(Cipher.DECRYPT_MODE, privateKey, oaep)
83
+ return cipher
84
+ }
85
+
86
+ @ReactMethod
87
+ fun decryptRsaOaep(alias: String, ciphertextB64: String, requireBiometric: Boolean, promise: Promise) {
88
+ try {
89
+ val privateKey = getPrivateKey(alias)
90
+ val ct = Base64.decode(ciphertextB64, Base64.DEFAULT)
91
+
92
+ val activity: Activity? = currentActivity
93
+ if (requireBiometric) {
94
+ if (activity == null) {
95
+ promise.reject("NO_ACTIVITY", "No current activity for biometric prompt")
96
+ return
97
+ }
98
+ activity.runOnUiThread {
99
+ try {
100
+ val cipher = newOaepCipherForDecrypt(privateKey)
101
+ val executor = ContextCompat.getMainExecutor(activity)
102
+ val callback = object : BiometricPrompt.AuthenticationCallback() {
103
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
104
+ try {
105
+ val c = result.cryptoObject?.cipher ?: cipher
106
+ val pt = c.doFinal(ct)
107
+ val outB64 = Base64.encodeToString(pt, Base64.NO_WRAP)
108
+ promise.resolve(outB64)
109
+ } catch (e: Exception) {
110
+ promise.reject("DECRYPT_ERR", e)
111
+ }
112
+ }
113
+ override fun onAuthenticationError(code: Int, errString: CharSequence) {
114
+ promise.reject("AUTH_ERR", Exception("$code: $errString"))
115
+ }
116
+ }
117
+ val prompt = BiometricPrompt(activity, executor, callback)
118
+ val info = BiometricPrompt.PromptInfo.Builder()
119
+ .setTitle("Xác thực để giải mã")
120
+ .setSubtitle("Dùng vân tay/face để giải mã secret")
121
+ .setNegativeButtonText("Huỷ")
122
+ .build()
123
+ prompt.authenticate(BiometricPrompt.CryptoObject(cipher))
124
+ } catch (e: Exception) {
125
+ promise.reject("PROMPT_ERR", e)
126
+ }
127
+ }
128
+ } else {
129
+ val cipher = newOaepCipherForDecrypt(privateKey)
130
+ val pt = cipher.doFinal(ct)
131
+ val outB64 = Base64.encodeToString(pt, Base64.NO_WRAP)
132
+ promise.resolve(outB64)
133
+ }
134
+ } catch (e: Exception) {
135
+ promise.reject("DECRYPT_ERR", e)
136
+ }
137
+ }
138
+ }
@@ -0,0 +1,15 @@
1
+ package com.keystorecrypto
2
+
3
+ import com.facebook.react.ReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.uimanager.ViewManager
7
+
8
+ class KeystoreCryptoPackage : ReactPackage {
9
+ override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
10
+ return listOf(KeystoreCryptoModule(reactContext))
11
+ }
12
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
13
+ return emptyList()
14
+ }
15
+ }
@@ -0,0 +1,15 @@
1
+ #import <React/RCTBridgeModule.h>
2
+
3
+ @interface RCT_EXTERN_MODULE(KeystoreCrypto, NSObject)
4
+ RCT_EXTERN_METHOD(generateKeyPair:(NSString *)alias
5
+ resolver:(RCTPromiseResolveBlock)resolve
6
+ rejecter:(RCTPromiseRejectBlock)reject)
7
+ RCT_EXTERN_METHOD(getPublicKeyPem:(NSString *)alias
8
+ resolver:(RCTPromiseResolveBlock)resolve
9
+ rejecter:(RCTPromiseRejectBlock)reject)
10
+ RCT_EXTERN_METHOD(decryptRsaOaep:(NSString *)alias
11
+ ciphertextB64:(NSString *)ciphertextB64
12
+ requireBiometric:(BOOL)requireBiometric
13
+ resolver:(RCTPromiseResolveBlock)resolve
14
+ rejecter:(RCTPromiseRejectBlock)reject)
15
+ @end
@@ -0,0 +1,109 @@
1
+ import Foundation
2
+ import LocalAuthentication
3
+ import Security
4
+
5
+ @objc(KeystoreCrypto)
6
+ class KeystoreCrypto: NSObject {
7
+
8
+ @objc
9
+ func generateKeyPair(_ alias: String,
10
+ resolver resolve: RCTPromiseResolveBlock,
11
+ rejecter reject: RCTPromiseRejectBlock) {
12
+ let tag = alias.data(using: .utf8)!
13
+
14
+ let access = SecAccessControlCreateWithFlags(
15
+ nil,
16
+ kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
17
+ .evaluatorAuthenticated, // requires auth (biometry/passcode)
18
+ nil
19
+ )!
20
+
21
+ let attributes: [String: Any] = [
22
+ kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
23
+ kSecAttrKeySizeInBits as String: 2048,
24
+ kSecAttrIsPermanent as String: true,
25
+ kSecAttrApplicationTag as String: tag,
26
+ kSecAttrAccessControl as String: access
27
+ ]
28
+
29
+ var error: Unmanaged<CFError>?
30
+ if let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) {
31
+ _ = SecKeyCopyPublicKey(privateKey)
32
+ resolve(nil)
33
+ } else {
34
+ reject("GEN_KEY_ERR", "Failed to create RSA key", error?.takeRetainedValue())
35
+ }
36
+ }
37
+
38
+ @objc
39
+ func getPublicKeyPem(_ alias: String,
40
+ resolver resolve: RCTPromiseResolveBlock,
41
+ rejecter reject: RCTPromiseRejectBlock) {
42
+ let tag = alias.data(using: .utf8)!
43
+ let q: [String: Any] = [
44
+ kSecClass as String: kSecClassKey,
45
+ kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
46
+ kSecAttrApplicationTag as String: tag,
47
+ kSecReturnRef as String: true
48
+ ]
49
+ var item: CFTypeRef?
50
+ let st = SecItemCopyMatching(q as CFDictionary, &item)
51
+ guard st == errSecSuccess, let priv = item as! SecKey? else {
52
+ reject("NO_KEY", "Key not found", nil); return
53
+ }
54
+ guard let pub = SecKeyCopyPublicKey(priv) else {
55
+ reject("NO_PUB", "No public key", nil); return
56
+ }
57
+ var err: Unmanaged<CFError>?
58
+ guard let spki = SecKeyCopyExternalRepresentation(pub, &err) as Data? else {
59
+ reject("EXPORT_ERR", "Export pub failed", err?.takeRetainedValue()); return
60
+ }
61
+ let b64 = spki.base64EncodedString(options: [.lineLength64Characters])
62
+ let pem = "-----BEGIN PUBLIC KEY-----\n\(b64)\n-----END PUBLIC KEY-----"
63
+ resolve(pem)
64
+ }
65
+
66
+ @objc
67
+ func decryptRsaOaep(_ alias: String,
68
+ ciphertextB64: String,
69
+ requireBiometric: Bool,
70
+ resolver resolve: RCTPromiseResolveBlock,
71
+ rejecter reject: RCTPromiseRejectBlock) {
72
+
73
+ let tag = alias.data(using: .utf8)!
74
+ let q: [String: Any] = [
75
+ kSecClass as String: kSecClassKey,
76
+ kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
77
+ kSecAttrApplicationTag as String: tag,
78
+ kSecReturnRef as String: true
79
+ ]
80
+ var item: CFTypeRef?
81
+ let st = SecItemCopyMatching(q as CFDictionary, &item)
82
+ guard st == errSecSuccess, let privateKey = item as! SecKey? else {
83
+ reject("NO_KEY", "Key not found", nil); return
84
+ }
85
+
86
+ guard let ct = Data(base64Encoded: ciphertextB64) else {
87
+ reject("BAD_INPUT", "ciphertextB64 invalid", nil); return
88
+ }
89
+
90
+ var context: LAContext? = nil
91
+ if requireBiometric {
92
+ let c = LAContext()
93
+ c.localizedReason = "Authenticate to decrypt secret"
94
+ context = c
95
+ }
96
+
97
+ let alg = SecKeyAlgorithm.rsaEncryptionOAEPSHA256
98
+ guard SecKeyIsAlgorithmSupported(privateKey, .decrypt, alg) else {
99
+ reject("ALG_UNSUPPORTED", "Algorithm not supported", nil); return
100
+ }
101
+
102
+ var err: Unmanaged<CFError>?
103
+ if let clear = SecKeyCreateDecryptedData(privateKey, alg, ct as CFData, &err) as Data? {
104
+ resolve(clear.base64EncodedString())
105
+ } else {
106
+ reject("DECRYPT_ERR", "Decrypt failed", err?.takeRetainedValue())
107
+ }
108
+ }
109
+ }
@@ -0,0 +1,11 @@
1
+ declare module 'react-native-keystore-crypto' {
2
+ export function generateKeyPair(alias: string): Promise<void>;
3
+ export function getPublicKeyPem(alias: string): Promise<string>;
4
+ export function decryptRsaOaep(alias: string, ciphertextB64: string, requireBiometric?: boolean): Promise<string>;
5
+ const _default: {
6
+ generateKeyPair: typeof generateKeyPair;
7
+ getPublicKeyPem: typeof getPublicKeyPem;
8
+ decryptRsaOaep: typeof decryptRsaOaep;
9
+ };
10
+ export default _default;
11
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "react-native-keystore-crypto-joe",
3
+ "version": "0.1.0",
4
+ "description": "RSA OAEP decrypt via Android Keystore / iOS Keychain + biometric gating for React Native",
5
+ "main": "src/index.ts",
6
+ "module": "src/index.ts",
7
+ "types": "lib/typescript/src/index.d.ts",
8
+ "react-native": "src/index",
9
+ "keywords": [
10
+ "react-native",
11
+ "keystore",
12
+ "keychain",
13
+ "rsa",
14
+ "oaep",
15
+ "biometric",
16
+ "secure",
17
+ "crypto"
18
+ ],
19
+ "author": "Your Name",
20
+ "license": "MIT",
21
+ "homepage": "https://github.com/yourname/react-native-keystore-crypto",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/yourname/react-native-keystore-crypto.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/yourname/react-native-keystore-crypto/issues"
28
+ },
29
+ "scripts": {
30
+ "build": "echo No build step required for Git install",
31
+ "prepare": "echo Skipping prepare on Git install"
32
+ },
33
+ "peerDependencies": {
34
+ "react": "*",
35
+ "react-native": "*"
36
+ },
37
+ "devDependencies": {}
38
+ }
@@ -0,0 +1,13 @@
1
+ Pod::Spec.new do |s|
2
+ s.name = "react-native-keystore-crypto"
3
+ s.version = "0.1.0"
4
+ s.summary = "RSA OAEP decrypt via Keychain/Keystore for React Native"
5
+ s.license = { :type => "MIT" }
6
+ s.author = { "Your Name" => "you@example.com" }
7
+ s.homepage = "https://github.com/yourname/react-native-keystore-crypto"
8
+ s.source = { :git => "https://github.com/yourname/react-native-keystore-crypto.git", :tag => "#{s.version}" }
9
+ s.platforms = { :ios => "12.0" }
10
+ s.source_files = "ios/*.{h,m,mm,swift}"
11
+ s.requires_arc = true
12
+ s.dependency 'React-Core'
13
+ end
@@ -0,0 +1,8 @@
1
+ module.exports = {
2
+ dependency: {
3
+ platforms: {
4
+ android: {},
5
+ ios: {},
6
+ },
7
+ },
8
+ };
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { NativeModules } from 'react-native';
2
+
3
+ type Native = {
4
+ generateKeyPair(alias: string): Promise<void>;
5
+ getPublicKeyPem(alias: string): Promise<string>;
6
+ decryptRsaOaep(alias: string, ciphertextB64: string, requireBiometric?: boolean): Promise<string>;
7
+ };
8
+
9
+ const { KeystoreCrypto } = NativeModules;
10
+ if (!KeystoreCrypto) {
11
+ throw new Error('KeystoreCrypto native module not linked');
12
+ }
13
+
14
+ export default KeystoreCrypto as Native;