@dynamic-labs/react-native-extension 4.70.0 → 4.72.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/android/AndroidManifest.xml +1 -0
- package/android/KeyStoreKeyManager.kt +148 -0
- package/android/KeychainModule.kt +71 -0
- package/android/build.gradle +38 -0
- package/android/dynamic/keychain/KeyStoreKeyManager.kt +148 -0
- package/android/dynamic/keychain/KeychainModule.kt +71 -0
- package/android/java/xyz/dynamic/keychain/KeyStoreKeyManager.kt +148 -0
- package/android/java/xyz/dynamic/keychain/KeychainModule.kt +71 -0
- package/android/keychain/KeyStoreKeyManager.kt +148 -0
- package/android/keychain/KeychainModule.kt +71 -0
- package/android/main/AndroidManifest.xml +1 -0
- package/android/main/java/xyz/dynamic/keychain/KeyStoreKeyManager.kt +148 -0
- package/android/main/java/xyz/dynamic/keychain/KeychainModule.kt +71 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/xyz/dynamic/keychain/KeyStoreKeyManager.kt +148 -0
- package/android/src/main/java/xyz/dynamic/keychain/KeychainModule.kt +71 -0
- package/android/xyz/dynamic/keychain/KeyStoreKeyManager.kt +148 -0
- package/android/xyz/dynamic/keychain/KeychainModule.kt +71 -0
- package/expo-module.config.json +9 -0
- package/index.cjs +57 -1
- package/index.js +57 -1
- package/ios/Keychain.podspec +15 -0
- package/ios/KeychainModule.swift +39 -0
- package/ios/SecureEnclaveKeyManager.swift +187 -0
- package/package.json +8 -7
- package/src/ReactNativeExtension/setupKeychainHandler/index.d.ts +1 -0
- package/src/ReactNativeExtension/setupKeychainHandler/setupKeychainHandler.d.ts +2 -0
- package/src/nativeModules/Keychain.d.ts +16 -0
- package/src/nativeModules/index.d.ts +1 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
package xyz.dynamic.keychain
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.pm.PackageManager
|
|
5
|
+
import android.os.Build
|
|
6
|
+
import android.security.keystore.KeyGenParameterSpec
|
|
7
|
+
import android.security.keystore.KeyProperties
|
|
8
|
+
import android.security.keystore.StrongBoxUnavailableException
|
|
9
|
+
import java.security.KeyPairGenerator
|
|
10
|
+
import java.security.KeyStore
|
|
11
|
+
import java.security.Signature
|
|
12
|
+
import java.security.spec.ECGenParameterSpec
|
|
13
|
+
|
|
14
|
+
/// Platform-agnostic Android KeyStore key manager.
|
|
15
|
+
/// Provides P-256 key generation, signing, and management backed by hardware TEE/StrongBox.
|
|
16
|
+
/// All public keys are returned in uncompressed SEC1 format (65 bytes: 04 || x || y).
|
|
17
|
+
/// All binary data uses base64url encoding (RFC 4648 §5, no padding).
|
|
18
|
+
class KeyStoreKeyManager {
|
|
19
|
+
|
|
20
|
+
fun isAvailable(context: Context?): Boolean {
|
|
21
|
+
return try {
|
|
22
|
+
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
|
|
23
|
+
keyStore.load(null)
|
|
24
|
+
val hasStrongBox = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && context != null) {
|
|
25
|
+
context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
|
|
26
|
+
} else {
|
|
27
|
+
false
|
|
28
|
+
}
|
|
29
|
+
// Even without StrongBox, Android KeyStore provides TEE-backed keys on most devices
|
|
30
|
+
hasStrongBox || Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
|
|
31
|
+
} catch (e: Exception) {
|
|
32
|
+
false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
fun hasKey(alias: String): Boolean {
|
|
37
|
+
val keyStore = loadKeyStore()
|
|
38
|
+
return keyStore.containsAlias(alias)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fun generateKeyPair(alias: String): String {
|
|
42
|
+
require(!hasKey(alias)) { "Key already exists for alias: $alias" }
|
|
43
|
+
|
|
44
|
+
val specBuilder = KeyGenParameterSpec.Builder(
|
|
45
|
+
alias,
|
|
46
|
+
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
|
|
47
|
+
)
|
|
48
|
+
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
|
|
49
|
+
.setDigests(KeyProperties.DIGEST_SHA256)
|
|
50
|
+
|
|
51
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
52
|
+
specBuilder.setIsStrongBoxBacked(true)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
val keyPair = try {
|
|
56
|
+
val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEYSTORE)
|
|
57
|
+
kpg.initialize(specBuilder.build())
|
|
58
|
+
kpg.generateKeyPair()
|
|
59
|
+
} catch (e: Exception) {
|
|
60
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && e is StrongBoxUnavailableException) {
|
|
61
|
+
// Retry without StrongBox
|
|
62
|
+
specBuilder.setIsStrongBoxBacked(false)
|
|
63
|
+
val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEYSTORE)
|
|
64
|
+
kpg.initialize(specBuilder.build())
|
|
65
|
+
kpg.generateKeyPair()
|
|
66
|
+
} else {
|
|
67
|
+
throw e
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
val publicKeyBytes = extractUncompressedPublicKey(keyPair.public.encoded)
|
|
72
|
+
return base64urlEncode(publicKeyBytes)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fun getPublicKey(alias: String): String? {
|
|
76
|
+
val keyStore = loadKeyStore()
|
|
77
|
+
val entry = keyStore.getEntry(alias, null)
|
|
78
|
+
|
|
79
|
+
if (entry == null || entry !is KeyStore.PrivateKeyEntry) {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
val publicKeyBytes = extractUncompressedPublicKey(entry.certificate.publicKey.encoded)
|
|
84
|
+
return base64urlEncode(publicKeyBytes)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fun sign(alias: String, payload: ByteArray): String {
|
|
88
|
+
val keyStore = loadKeyStore()
|
|
89
|
+
val entry = keyStore.getEntry(alias, null)
|
|
90
|
+
|
|
91
|
+
require(entry != null && entry is KeyStore.PrivateKeyEntry) { "Key not found: $alias" }
|
|
92
|
+
|
|
93
|
+
val signature = Signature.getInstance("SHA256withECDSA")
|
|
94
|
+
signature.initSign(entry.privateKey)
|
|
95
|
+
signature.update(payload)
|
|
96
|
+
val signatureBytes = signature.sign()
|
|
97
|
+
|
|
98
|
+
return base64urlEncode(signatureBytes)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
fun deleteKey(alias: String) {
|
|
102
|
+
val keyStore = loadKeyStore()
|
|
103
|
+
keyStore.deleteEntry(alias)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// region Private helpers
|
|
107
|
+
|
|
108
|
+
private fun loadKeyStore(): KeyStore {
|
|
109
|
+
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
|
|
110
|
+
keyStore.load(null)
|
|
111
|
+
return keyStore
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Extract uncompressed SEC1 public key (65 bytes: 04 || x || y)
|
|
116
|
+
* from X.509 SubjectPublicKeyInfo DER encoding.
|
|
117
|
+
*
|
|
118
|
+
* For a P-256 key, the SubjectPublicKeyInfo contains the uncompressed
|
|
119
|
+
* point at the end of the DER structure. The point is always 65 bytes.
|
|
120
|
+
*/
|
|
121
|
+
private fun extractUncompressedPublicKey(x509Encoded: ByteArray): ByteArray {
|
|
122
|
+
val uncompressedPointLength = 65
|
|
123
|
+
return x509Encoded.copyOfRange(
|
|
124
|
+
x509Encoded.size - uncompressedPointLength,
|
|
125
|
+
x509Encoded.size
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private fun base64urlEncode(data: ByteArray): String {
|
|
130
|
+
return android.util.Base64.encodeToString(
|
|
131
|
+
data,
|
|
132
|
+
android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
companion object {
|
|
137
|
+
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
|
138
|
+
|
|
139
|
+
fun base64urlDecode(input: String): ByteArray {
|
|
140
|
+
return android.util.Base64.decode(
|
|
141
|
+
input,
|
|
142
|
+
android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// endregion
|
|
148
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
package xyz.dynamic.keychain
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.modules.Module
|
|
4
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
5
|
+
import expo.modules.kotlin.Promise
|
|
6
|
+
|
|
7
|
+
class KeychainModule : Module() {
|
|
8
|
+
private val keyManager = KeyStoreKeyManager()
|
|
9
|
+
|
|
10
|
+
override fun definition() = ModuleDefinition { // NOSONAR (cognitive complexity — inherent to Expo module DSL pattern)
|
|
11
|
+
Name("Keychain")
|
|
12
|
+
|
|
13
|
+
AsyncFunction("isAvailable") { promise: Promise ->
|
|
14
|
+
try {
|
|
15
|
+
val context = appContext.reactContext
|
|
16
|
+
promise.resolve(keyManager.isAvailable(context))
|
|
17
|
+
} catch (e: Exception) {
|
|
18
|
+
promise.resolve(false)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
AsyncFunction("hasKey") { key: String, promise: Promise ->
|
|
23
|
+
try {
|
|
24
|
+
promise.resolve(keyManager.hasKey(key))
|
|
25
|
+
} catch (e: Exception) {
|
|
26
|
+
promise.reject("ERR_KEYCHAIN", "Failed to check key: ${e.message}", e)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
AsyncFunction("generateKeyPair") { key: String, promise: Promise ->
|
|
31
|
+
try {
|
|
32
|
+
val publicKey = keyManager.generateKeyPair(key)
|
|
33
|
+
promise.resolve(mapOf("publicKey" to publicKey))
|
|
34
|
+
} catch (e: Exception) {
|
|
35
|
+
promise.reject("ERR_KEYCHAIN", "Failed to generate key pair: ${e.message}", e)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
AsyncFunction("getPublicKey") { key: String, promise: Promise ->
|
|
40
|
+
try {
|
|
41
|
+
val publicKey = keyManager.getPublicKey(key)
|
|
42
|
+
if (publicKey == null) {
|
|
43
|
+
promise.resolve(null)
|
|
44
|
+
} else {
|
|
45
|
+
promise.resolve(mapOf("publicKey" to publicKey))
|
|
46
|
+
}
|
|
47
|
+
} catch (e: Exception) {
|
|
48
|
+
promise.reject("ERR_KEYCHAIN", "Failed to get public key: ${e.message}", e)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
AsyncFunction("sign") { key: String, payload: String, promise: Promise ->
|
|
53
|
+
try {
|
|
54
|
+
val payloadData = KeyStoreKeyManager.base64urlDecode(payload)
|
|
55
|
+
val signature = keyManager.sign(key, payloadData)
|
|
56
|
+
promise.resolve(mapOf("signature" to signature))
|
|
57
|
+
} catch (e: Exception) {
|
|
58
|
+
promise.reject("ERR_KEYCHAIN", "Failed to sign: ${e.message}", e)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
AsyncFunction("deleteKey") { key: String, promise: Promise ->
|
|
63
|
+
try {
|
|
64
|
+
keyManager.deleteKey(key)
|
|
65
|
+
promise.resolve(null)
|
|
66
|
+
} catch (e: Exception) {
|
|
67
|
+
promise.reject("ERR_KEYCHAIN", "Failed to delete key: ${e.message}", e)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
package/index.cjs
CHANGED
|
@@ -14,6 +14,7 @@ var expoLinking = require('expo-linking');
|
|
|
14
14
|
var expoWebBrowser = require('expo-web-browser');
|
|
15
15
|
var expoSecureStore = require('expo-secure-store');
|
|
16
16
|
var reactNativePasskeyStamper = require('@turnkey/react-native-passkey-stamper');
|
|
17
|
+
var expoModulesCore = require('expo-modules-core');
|
|
17
18
|
|
|
18
19
|
function _interopNamespace(e) {
|
|
19
20
|
if (e && e.__esModule) return e;
|
|
@@ -33,7 +34,7 @@ function _interopNamespace(e) {
|
|
|
33
34
|
return Object.freeze(n);
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
var version = "4.
|
|
37
|
+
var version = "4.72.0";
|
|
37
38
|
|
|
38
39
|
function _extends() {
|
|
39
40
|
return _extends = Object.assign ? Object.assign.bind() : function (n) {
|
|
@@ -646,6 +647,60 @@ const setupTurnkeyPasskeyHandler = core => {
|
|
|
646
647
|
}));
|
|
647
648
|
};
|
|
648
649
|
|
|
650
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
651
|
+
const getKeychain = () => {
|
|
652
|
+
try {
|
|
653
|
+
const keychain = expoModulesCore.requireNativeModule('Keychain');
|
|
654
|
+
return keychain;
|
|
655
|
+
} catch (error) {
|
|
656
|
+
logger.warn('Could not get dynamic keychain, using unavailable keychain');
|
|
657
|
+
const keychainUnavailableError = new Error('Keychain is not available');
|
|
658
|
+
const unavailableKeychain = {
|
|
659
|
+
deleteKey: () => Promise.reject(keychainUnavailableError),
|
|
660
|
+
generateKeyPair: () => Promise.reject(keychainUnavailableError),
|
|
661
|
+
getPublicKey: () => Promise.reject(keychainUnavailableError),
|
|
662
|
+
hasKey: () => Promise.reject(keychainUnavailableError),
|
|
663
|
+
isAvailable: () => Promise.resolve(false),
|
|
664
|
+
sign: () => Promise.reject(keychainUnavailableError)
|
|
665
|
+
};
|
|
666
|
+
return unavailableKeychain;
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
const setupKeychainHandler = core => {
|
|
671
|
+
const keychainRequestChannel = messageTransport.createRequestChannel(core.messageTransport);
|
|
672
|
+
const keychain = getKeychain();
|
|
673
|
+
keychainRequestChannel.handle('keychain_isAvailable', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
674
|
+
return keychain.isAvailable();
|
|
675
|
+
}));
|
|
676
|
+
keychainRequestChannel.handle('keychain_hasKey', _a => __awaiter(void 0, [_a], void 0, function* ({
|
|
677
|
+
key
|
|
678
|
+
}) {
|
|
679
|
+
return keychain.hasKey(key);
|
|
680
|
+
}));
|
|
681
|
+
keychainRequestChannel.handle('keychain_generateKey', _b => __awaiter(void 0, [_b], void 0, function* ({
|
|
682
|
+
key
|
|
683
|
+
}) {
|
|
684
|
+
return keychain.generateKeyPair(key);
|
|
685
|
+
}));
|
|
686
|
+
keychainRequestChannel.handle('keychain_getPublicKey', _c => __awaiter(void 0, [_c], void 0, function* ({
|
|
687
|
+
key
|
|
688
|
+
}) {
|
|
689
|
+
return keychain.getPublicKey(key);
|
|
690
|
+
}));
|
|
691
|
+
keychainRequestChannel.handle('keychain_sign', _d => __awaiter(void 0, [_d], void 0, function* ({
|
|
692
|
+
key,
|
|
693
|
+
payload
|
|
694
|
+
}) {
|
|
695
|
+
return keychain.sign(key, payload);
|
|
696
|
+
}));
|
|
697
|
+
keychainRequestChannel.handle('keychain_removeKey', _e => __awaiter(void 0, [_e], void 0, function* ({
|
|
698
|
+
key
|
|
699
|
+
}) {
|
|
700
|
+
return keychain.deleteKey(key);
|
|
701
|
+
}));
|
|
702
|
+
};
|
|
703
|
+
|
|
649
704
|
const defaultWebviewUrl = `https://webview.dynamicauth.com/${version}`;
|
|
650
705
|
const ReactNativeExtension = ({
|
|
651
706
|
webviewUrl: _webviewUrl = defaultWebviewUrl,
|
|
@@ -669,6 +724,7 @@ const ReactNativeExtension = ({
|
|
|
669
724
|
setupTurnkeyPasskeyHandler(core);
|
|
670
725
|
setupPlatformHandler(core);
|
|
671
726
|
setupStorageHandler(core);
|
|
727
|
+
setupKeychainHandler(core);
|
|
672
728
|
return {
|
|
673
729
|
reactNative: {
|
|
674
730
|
WebView: createWebView({
|
package/index.js
CHANGED
|
@@ -10,8 +10,9 @@ import { createURL, getInitialURL, addEventListener, openURL } from 'expo-linkin
|
|
|
10
10
|
import { openAuthSessionAsync } from 'expo-web-browser';
|
|
11
11
|
import { getItemAsync, deleteItemAsync, setItemAsync } from 'expo-secure-store';
|
|
12
12
|
import { createPasskey, PasskeyStamper } from '@turnkey/react-native-passkey-stamper';
|
|
13
|
+
import { requireNativeModule } from 'expo-modules-core';
|
|
13
14
|
|
|
14
|
-
var version = "4.
|
|
15
|
+
var version = "4.72.0";
|
|
15
16
|
|
|
16
17
|
function _extends() {
|
|
17
18
|
return _extends = Object.assign ? Object.assign.bind() : function (n) {
|
|
@@ -624,6 +625,60 @@ const setupTurnkeyPasskeyHandler = core => {
|
|
|
624
625
|
}));
|
|
625
626
|
};
|
|
626
627
|
|
|
628
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
629
|
+
const getKeychain = () => {
|
|
630
|
+
try {
|
|
631
|
+
const keychain = requireNativeModule('Keychain');
|
|
632
|
+
return keychain;
|
|
633
|
+
} catch (error) {
|
|
634
|
+
logger.warn('Could not get dynamic keychain, using unavailable keychain');
|
|
635
|
+
const keychainUnavailableError = new Error('Keychain is not available');
|
|
636
|
+
const unavailableKeychain = {
|
|
637
|
+
deleteKey: () => Promise.reject(keychainUnavailableError),
|
|
638
|
+
generateKeyPair: () => Promise.reject(keychainUnavailableError),
|
|
639
|
+
getPublicKey: () => Promise.reject(keychainUnavailableError),
|
|
640
|
+
hasKey: () => Promise.reject(keychainUnavailableError),
|
|
641
|
+
isAvailable: () => Promise.resolve(false),
|
|
642
|
+
sign: () => Promise.reject(keychainUnavailableError)
|
|
643
|
+
};
|
|
644
|
+
return unavailableKeychain;
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
const setupKeychainHandler = core => {
|
|
649
|
+
const keychainRequestChannel = createRequestChannel(core.messageTransport);
|
|
650
|
+
const keychain = getKeychain();
|
|
651
|
+
keychainRequestChannel.handle('keychain_isAvailable', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
652
|
+
return keychain.isAvailable();
|
|
653
|
+
}));
|
|
654
|
+
keychainRequestChannel.handle('keychain_hasKey', _a => __awaiter(void 0, [_a], void 0, function* ({
|
|
655
|
+
key
|
|
656
|
+
}) {
|
|
657
|
+
return keychain.hasKey(key);
|
|
658
|
+
}));
|
|
659
|
+
keychainRequestChannel.handle('keychain_generateKey', _b => __awaiter(void 0, [_b], void 0, function* ({
|
|
660
|
+
key
|
|
661
|
+
}) {
|
|
662
|
+
return keychain.generateKeyPair(key);
|
|
663
|
+
}));
|
|
664
|
+
keychainRequestChannel.handle('keychain_getPublicKey', _c => __awaiter(void 0, [_c], void 0, function* ({
|
|
665
|
+
key
|
|
666
|
+
}) {
|
|
667
|
+
return keychain.getPublicKey(key);
|
|
668
|
+
}));
|
|
669
|
+
keychainRequestChannel.handle('keychain_sign', _d => __awaiter(void 0, [_d], void 0, function* ({
|
|
670
|
+
key,
|
|
671
|
+
payload
|
|
672
|
+
}) {
|
|
673
|
+
return keychain.sign(key, payload);
|
|
674
|
+
}));
|
|
675
|
+
keychainRequestChannel.handle('keychain_removeKey', _e => __awaiter(void 0, [_e], void 0, function* ({
|
|
676
|
+
key
|
|
677
|
+
}) {
|
|
678
|
+
return keychain.deleteKey(key);
|
|
679
|
+
}));
|
|
680
|
+
};
|
|
681
|
+
|
|
627
682
|
const defaultWebviewUrl = `https://webview.dynamicauth.com/${version}`;
|
|
628
683
|
const ReactNativeExtension = ({
|
|
629
684
|
webviewUrl: _webviewUrl = defaultWebviewUrl,
|
|
@@ -647,6 +702,7 @@ const ReactNativeExtension = ({
|
|
|
647
702
|
setupTurnkeyPasskeyHandler(core);
|
|
648
703
|
setupPlatformHandler(core);
|
|
649
704
|
setupStorageHandler(core);
|
|
705
|
+
setupKeychainHandler(core);
|
|
650
706
|
return {
|
|
651
707
|
reactNative: {
|
|
652
708
|
WebView: createWebView({
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Pod::Spec.new do |s|
|
|
2
|
+
s.name = 'Keychain'
|
|
3
|
+
s.version = '1.0.0'
|
|
4
|
+
s.summary = 'TEE-backed key operations for Dynamic SDK'
|
|
5
|
+
s.description = 'Provides Secure Enclave key generation, signing, and management via Expo Modules'
|
|
6
|
+
s.homepage = 'https://www.dynamic.xyz'
|
|
7
|
+
s.license = { type: 'MIT' }
|
|
8
|
+
s.author = 'Dynamic Labs'
|
|
9
|
+
s.source = { git: '' }
|
|
10
|
+
s.platform = :ios, '15.1'
|
|
11
|
+
s.swift_version = '5.4'
|
|
12
|
+
s.source_files = '*.swift'
|
|
13
|
+
|
|
14
|
+
s.dependency 'ExpoModulesCore'
|
|
15
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
public class KeychainModule: Module {
|
|
4
|
+
private let keyManager = SecureEnclaveKeyManager()
|
|
5
|
+
|
|
6
|
+
public func definition() -> ModuleDefinition {
|
|
7
|
+
Name("Keychain")
|
|
8
|
+
|
|
9
|
+
AsyncFunction("isAvailable") { () -> Bool in
|
|
10
|
+
self.keyManager.isAvailable()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
AsyncFunction("hasKey") { (key: String) -> Bool in
|
|
14
|
+
self.keyManager.hasKey(tag: key)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
AsyncFunction("generateKeyPair") { (key: String) -> [String: String] in
|
|
18
|
+
let publicKey = try self.keyManager.generateKeyPair(tag: key)
|
|
19
|
+
return ["publicKey": publicKey]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
AsyncFunction("getPublicKey") { (key: String) -> [String: String]? in
|
|
23
|
+
guard let publicKey = try self.keyManager.getPublicKey(tag: key) else {
|
|
24
|
+
return nil
|
|
25
|
+
}
|
|
26
|
+
return ["publicKey": publicKey]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
AsyncFunction("sign") { (key: String, payload: String) -> [String: String] in
|
|
30
|
+
let payloadData = try base64urlDecode(payload)
|
|
31
|
+
let signature = try self.keyManager.sign(tag: key, payload: payloadData)
|
|
32
|
+
return ["signature": signature]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
AsyncFunction("deleteKey") { (key: String) in
|
|
36
|
+
self.keyManager.deleteKey(tag: key)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Security
|
|
3
|
+
import CryptoKit
|
|
4
|
+
|
|
5
|
+
public enum SecureEnclaveKeyManagerError: Error, LocalizedError {
|
|
6
|
+
case generateFailed(String)
|
|
7
|
+
case exportFailed(String)
|
|
8
|
+
case keyNotFound(String)
|
|
9
|
+
case signFailed(String)
|
|
10
|
+
case invalidBase64Input
|
|
11
|
+
|
|
12
|
+
public var errorDescription: String? {
|
|
13
|
+
switch self {
|
|
14
|
+
case .generateFailed(let detail):
|
|
15
|
+
return "Failed to generate key pair: \(detail)"
|
|
16
|
+
case .exportFailed(let detail):
|
|
17
|
+
return "Failed to export public key: \(detail)"
|
|
18
|
+
case .keyNotFound(let tag):
|
|
19
|
+
return "Key not found for tag: \(tag)"
|
|
20
|
+
case .signFailed(let detail):
|
|
21
|
+
return "Failed to sign: \(detail)"
|
|
22
|
+
case .invalidBase64Input:
|
|
23
|
+
return "Invalid base64url-encoded input"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// Platform-agnostic Secure Enclave key manager.
|
|
29
|
+
/// Provides P-256 key generation, signing, and management backed by the iOS Secure Enclave.
|
|
30
|
+
/// All public keys are returned in uncompressed SEC1 format (65 bytes: 04 || x || y).
|
|
31
|
+
/// All binary data uses base64url encoding (RFC 4648 §5, no padding).
|
|
32
|
+
public class SecureEnclaveKeyManager {
|
|
33
|
+
|
|
34
|
+
// No state needed — all operations use the system Keychain and Secure Enclave directly
|
|
35
|
+
public init() {}
|
|
36
|
+
|
|
37
|
+
public func isAvailable() -> Bool {
|
|
38
|
+
if #available(iOS 13.0, *) {
|
|
39
|
+
return SecureEnclave.isAvailable
|
|
40
|
+
}
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public func hasKey(tag: String) -> Bool {
|
|
45
|
+
let query = baseQuery(for: tag, returning: true)
|
|
46
|
+
var item: CFTypeRef?
|
|
47
|
+
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
48
|
+
return status == errSecSuccess
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public func generateKeyPair(tag: String) throws -> String {
|
|
52
|
+
let tagData = tag.data(using: .utf8)!
|
|
53
|
+
|
|
54
|
+
let access = SecAccessControlCreateWithFlags(
|
|
55
|
+
kCFAllocatorDefault,
|
|
56
|
+
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
|
57
|
+
.privateKeyUsage,
|
|
58
|
+
nil
|
|
59
|
+
)!
|
|
60
|
+
|
|
61
|
+
let attributes: [String: Any] = [
|
|
62
|
+
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
|
63
|
+
kSecAttrKeySizeInBits as String: 256,
|
|
64
|
+
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
|
|
65
|
+
kSecPrivateKeyAttrs as String: [
|
|
66
|
+
kSecAttrIsPermanent as String: true,
|
|
67
|
+
kSecAttrApplicationTag as String: tagData,
|
|
68
|
+
kSecAttrAccessControl as String: access,
|
|
69
|
+
] as [String: Any],
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
var error: Unmanaged<CFError>?
|
|
73
|
+
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
|
|
74
|
+
throw SecureEnclaveKeyManagerError.generateFailed(
|
|
75
|
+
"\(error!.takeRetainedValue())"
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
|
|
80
|
+
throw SecureEnclaveKeyManagerError.exportFailed("Failed to extract public key")
|
|
81
|
+
}
|
|
82
|
+
let publicKeyData = try exportPublicKey(publicKey)
|
|
83
|
+
return base64urlEncode(publicKeyData)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
public func getPublicKey(tag: String) throws -> String? {
|
|
87
|
+
let query = baseQuery(for: tag, returning: true)
|
|
88
|
+
|
|
89
|
+
var item: CFTypeRef?
|
|
90
|
+
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
91
|
+
|
|
92
|
+
guard status == errSecSuccess else {
|
|
93
|
+
return nil
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
guard let item else {
|
|
97
|
+
throw SecureEnclaveKeyManagerError.keyNotFound(tag)
|
|
98
|
+
}
|
|
99
|
+
let privateKey = item as! SecKey
|
|
100
|
+
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
|
|
101
|
+
throw SecureEnclaveKeyManagerError.exportFailed("Failed to extract public key")
|
|
102
|
+
}
|
|
103
|
+
let publicKeyData = try exportPublicKey(publicKey)
|
|
104
|
+
return base64urlEncode(publicKeyData)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
public func sign(tag: String, payload: Data) throws -> String {
|
|
108
|
+
let query = baseQuery(for: tag, returning: true)
|
|
109
|
+
|
|
110
|
+
var item: CFTypeRef?
|
|
111
|
+
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
112
|
+
|
|
113
|
+
guard status == errSecSuccess else {
|
|
114
|
+
throw SecureEnclaveKeyManagerError.keyNotFound(tag)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
guard let item else {
|
|
118
|
+
throw SecureEnclaveKeyManagerError.keyNotFound(tag)
|
|
119
|
+
}
|
|
120
|
+
let privateKey = item as! SecKey
|
|
121
|
+
|
|
122
|
+
var error: Unmanaged<CFError>?
|
|
123
|
+
guard let signature = SecKeyCreateSignature(
|
|
124
|
+
privateKey,
|
|
125
|
+
.ecdsaSignatureMessageX962SHA256,
|
|
126
|
+
payload as CFData,
|
|
127
|
+
&error
|
|
128
|
+
) else {
|
|
129
|
+
throw SecureEnclaveKeyManagerError.signFailed(
|
|
130
|
+
"\(error!.takeRetainedValue())"
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return base64urlEncode(signature as Data)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
public func deleteKey(tag: String) {
|
|
138
|
+
let query = baseQuery(for: tag, returning: false)
|
|
139
|
+
SecItemDelete(query as CFDictionary)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// MARK: - Private helpers
|
|
143
|
+
|
|
144
|
+
private func baseQuery(for tag: String, returning returnRef: Bool) -> [String: Any] {
|
|
145
|
+
var query: [String: Any] = [
|
|
146
|
+
kSecClass as String: kSecClassKey,
|
|
147
|
+
kSecAttrApplicationTag as String: tag.data(using: .utf8)!,
|
|
148
|
+
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
|
149
|
+
]
|
|
150
|
+
if returnRef {
|
|
151
|
+
query[kSecReturnRef as String] = true
|
|
152
|
+
}
|
|
153
|
+
return query
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private func exportPublicKey(_ publicKey: SecKey) throws -> Data {
|
|
157
|
+
var error: Unmanaged<CFError>?
|
|
158
|
+
guard let data = SecKeyCopyExternalRepresentation(publicKey, &error) else {
|
|
159
|
+
throw SecureEnclaveKeyManagerError.exportFailed(
|
|
160
|
+
"\(error!.takeRetainedValue())"
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
// SecKeyCopyExternalRepresentation returns uncompressed SEC1 format (04 || x || y)
|
|
164
|
+
return data as Data
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private func base64urlEncode(_ data: Data) -> String {
|
|
168
|
+
data.base64EncodedString()
|
|
169
|
+
.replacingOccurrences(of: "+", with: "-")
|
|
170
|
+
.replacingOccurrences(of: "/", with: "_")
|
|
171
|
+
.replacingOccurrences(of: "=", with: "")
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
public func base64urlDecode(_ string: String) throws -> Data {
|
|
176
|
+
var base64 = string
|
|
177
|
+
.replacingOccurrences(of: "-", with: "+")
|
|
178
|
+
.replacingOccurrences(of: "_", with: "/")
|
|
179
|
+
let remainder = base64.count % 4
|
|
180
|
+
if remainder > 0 {
|
|
181
|
+
base64 += String(repeating: "=", count: 4 - remainder)
|
|
182
|
+
}
|
|
183
|
+
guard let data = Data(base64Encoded: base64) else {
|
|
184
|
+
throw SecureEnclaveKeyManagerError.invalidBase64Input
|
|
185
|
+
}
|
|
186
|
+
return data
|
|
187
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dynamic-labs/react-native-extension",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.72.0",
|
|
4
4
|
"main": "./index.cjs",
|
|
5
5
|
"module": "./index.js",
|
|
6
6
|
"types": "./src/index.d.ts",
|
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
"@turnkey/react-native-passkey-stamper": "1.2.7",
|
|
19
19
|
"@react-native-documents/picker": "^11.0.0",
|
|
20
20
|
"react-native-fs": ">=2.20.0",
|
|
21
|
-
"@dynamic-labs/assert-package-version": "4.
|
|
22
|
-
"@dynamic-labs/client": "4.
|
|
23
|
-
"@dynamic-labs/logger": "4.
|
|
24
|
-
"@dynamic-labs/message-transport": "4.
|
|
25
|
-
"@dynamic-labs/webview-messages": "4.
|
|
21
|
+
"@dynamic-labs/assert-package-version": "4.72.0",
|
|
22
|
+
"@dynamic-labs/client": "4.72.0",
|
|
23
|
+
"@dynamic-labs/logger": "4.72.0",
|
|
24
|
+
"@dynamic-labs/message-transport": "4.72.0",
|
|
25
|
+
"@dynamic-labs/webview-messages": "4.72.0"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
28
|
"react": ">=18.0.0 <20.0.0",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"react-native-webview": "^13.6.4",
|
|
31
31
|
"expo-linking": ">=6.2.2",
|
|
32
32
|
"expo-web-browser": ">=12.0.0",
|
|
33
|
-
"expo-secure-store": ">=12.0.0"
|
|
33
|
+
"expo-secure-store": ">=12.0.0",
|
|
34
|
+
"expo-modules-core": ">=2.0.0"
|
|
34
35
|
}
|
|
35
36
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { setupKeychainHandler } from './setupKeychainHandler';
|