react-native-dpop 0.1.0 → 0.3.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 CHANGED
@@ -10,13 +10,12 @@ React Native library for DPoP proof generation and key management.
10
10
  - Calculate JWK thumbprint (`SHA-256`, base64url).
11
11
  - Verify if a proof is bound to a given key alias.
12
12
  - Retrieve non-sensitive key metadata (hardware-backed, StrongBox info, etc.).
13
+ - iOS key storage uses Secure Enclave when available, with Keychain fallback.
13
14
 
14
15
  ## Platform Support
15
16
 
16
17
  - Android: supported.
17
- - iOS: planned.
18
-
19
- Current implementation throws on non-Android platforms.
18
+ - iOS: supported.
20
19
 
21
20
  ## Installation
22
21
 
@@ -24,6 +23,12 @@ Current implementation throws on non-Android platforms.
24
23
  npm install react-native-dpop
25
24
  ```
26
25
 
26
+ For iOS, install pods in your app project:
27
+
28
+ ```sh
29
+ cd ios && pod install
30
+ ```
31
+
27
32
  ## Quick Start
28
33
 
29
34
  ```ts
@@ -39,6 +44,7 @@ const dpop = await DPoP.generateProof({
39
44
  const proof = dpop.proof;
40
45
  const thumbprint = await dpop.calculateThumbprint();
41
46
  const publicJwk = await dpop.getPublicKey('JWK');
47
+ const isBound = await dpop.isBoundToAlias();
42
48
  ```
43
49
 
44
50
  ## API
@@ -48,6 +54,7 @@ const publicJwk = await dpop.getPublicKey('JWK');
48
54
  - `GenerateProofInput`
49
55
  - `DPoPProofContext`
50
56
  - `DPoPKeyInfo`
57
+ - `SecureHardwareFallbackReason = 'UNAVAILABLE' | 'PROVIDER_ERROR' | 'POLICY_REJECTED' | 'UNKNOWN'`
51
58
  - `PublicJwk`
52
59
  - `PublicKeyFormat = 'JWK' | 'DER' | 'RAW'`
53
60
 
@@ -78,6 +85,7 @@ const publicJwk = await dpop.getPublicKey('JWK');
78
85
  Native errors are rejected with codes such as:
79
86
 
80
87
  - `ERR_DPOP_GENERATE_PROOF`
88
+ - `ERR_DPOP_CALCULATE_THUMBPRINT`
81
89
  - `ERR_DPOP_PUBLIC_KEY`
82
90
  - `ERR_DPOP_SIGN_WITH_PRIVATE_KEY`
83
91
  - `ERR_DPOP_HAS_KEY_PAIR`
@@ -90,6 +98,16 @@ Native errors are rejected with codes such as:
90
98
  ## Notes
91
99
 
92
100
  - If no alias is provided, the default alias is `react-native-dpop`.
101
+ - `getKeyInfo` returns cross-platform fields and platform-specific details in `hardware`:
102
+ - Android: `hardware.android.strongBoxAvailable`, `hardware.android.strongBoxBacked`, `hardware.android.securityLevel`, `hardware.android.strongBoxFallbackReason`
103
+ - iOS: `hardware.ios.secureEnclaveAvailable`, `hardware.ios.secureEnclaveBacked`, `hardware.ios.securityLevel`, `hardware.ios.secureEnclaveFallbackReason`
104
+ - Fallback reasons are sanitized enums (no raw native error): `UNAVAILABLE`, `PROVIDER_ERROR`, `POLICY_REJECTED`, `UNKNOWN`.
105
+ - `securityLevel` semantics:
106
+ - `null`: no key material available (or not reported)
107
+ - `1`: not backed by secure enclave/strong dedicated hardware
108
+ - `2`: hardware-backed (iOS Secure Enclave, Android typically TEE)
109
+ - `3`: Android-only StrongBox (when reported by the device)
110
+ - On iOS, `securityLevel` is normalized by this library (`2` for Secure Enclave-backed keys, `1` for Keychain fallback), not a native Apple numeric level API.
93
111
  - `htm` is normalized to uppercase in proof generation.
94
112
  - `ath` is derived from `accessToken` (`SHA-256`, base64url) when provided.
95
113
  - `jti` and `iat` are auto-generated when omitted.
@@ -3,7 +3,8 @@ require "json"
3
3
  package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
4
 
5
5
  Pod::Spec.new do |s|
6
- s.name = "Dpop"
6
+ s.name = "ReactNativeDPoP"
7
+ s.module_name = "ReactNativeDPoP"
7
8
  s.version = package["version"]
8
9
  s.summary = package["description"]
9
10
  s.homepage = package["homepage"]
@@ -14,7 +15,10 @@ Pod::Spec.new do |s|
14
15
  s.source = { :git => "https://github.com/Cirilord/react-native-dpop.git", :tag => "#{s.version}" }
15
16
 
16
17
  s.source_files = "ios/**/*.{h,m,mm,swift,cpp}"
17
- s.private_header_files = "ios/**/*.h"
18
18
 
19
- install_modules_dependencies(s)
19
+ if respond_to?(:install_modules_dependencies, true)
20
+ install_modules_dependencies(s)
21
+ else
22
+ s.dependency "React-Core"
23
+ end
20
24
  end
@@ -1,5 +1,5 @@
1
1
  buildscript {
2
- ext.Dpop = [
2
+ ext.ReactNativeDPoP = [
3
3
  kotlinVersion: "2.0.21",
4
4
  minSdkVersion: 24,
5
5
  compileSdkVersion: 36,
@@ -11,7 +11,7 @@ buildscript {
11
11
  return rootProject.ext.get(prop)
12
12
  }
13
13
 
14
- return Dpop[prop]
14
+ return ReactNativeDPoP[prop]
15
15
  }
16
16
 
17
17
  repositories {
@@ -33,7 +33,7 @@ apply plugin: "kotlin-android"
33
33
  apply plugin: "com.facebook.react"
34
34
 
35
35
  android {
36
- namespace "com.dpop"
36
+ namespace "com.reactnativedpop"
37
37
 
38
38
  compileSdkVersion getExtOrDefault("compileSdkVersion")
39
39
 
@@ -1,4 +1,4 @@
1
- package com.dpop
1
+ package com.reactnativedpop
2
2
 
3
3
  import android.content.Context
4
4
  import android.content.pm.PackageManager
@@ -36,6 +36,14 @@ internal class DPoPKeyStore(private val context: Context) {
36
36
  companion object {
37
37
  private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
38
38
  private const val EC_CURVE = "secp256r1"
39
+ private const val META_PREFS = "react_native_dpop_keystore_meta"
40
+ private const val META_STRONGBOX_FALLBACK_PREFIX = "strongbox_fallback_reason_"
41
+ private const val REASON_UNAVAILABLE = "UNAVAILABLE"
42
+ private const val REASON_PROVIDER_ERROR = "PROVIDER_ERROR"
43
+ }
44
+
45
+ private val metadataPrefs by lazy {
46
+ context.getSharedPreferences(META_PREFS, Context.MODE_PRIVATE)
39
47
  }
40
48
 
41
49
  private val keyStore: KeyStore by lazy {
@@ -46,6 +54,7 @@ internal class DPoPKeyStore(private val context: Context) {
46
54
  if (keyStore.containsAlias(alias)) {
47
55
  keyStore.deleteEntry(alias)
48
56
  }
57
+ clearStrongBoxFallbackReason(alias)
49
58
  }
50
59
 
51
60
  fun generateKeyPair(alias: String): Boolean {
@@ -56,16 +65,20 @@ internal class DPoPKeyStore(private val context: Context) {
56
65
  if (keyStore.containsAlias(alias)) {
57
66
  keyStore.deleteEntry(alias)
58
67
  }
68
+ clearStrongBoxFallbackReason(alias)
59
69
 
60
70
  val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, KEYSTORE_PROVIDER)
61
71
  if (isStrongBoxEnabled()) {
62
72
  try {
63
73
  generator.initialize(buildSpec(alias, useStrongBox = true))
64
74
  generator.generateKeyPair()
75
+ clearStrongBoxFallbackReason(alias)
65
76
  return true
66
77
  } catch (_: StrongBoxUnavailableException) {
78
+ storeStrongBoxFallbackReason(alias, REASON_UNAVAILABLE)
67
79
  // Fallback to hardware-backed keystore when StrongBox is unavailable.
68
80
  } catch (_: ProviderException) {
81
+ storeStrongBoxFallbackReason(alias, REASON_PROVIDER_ERROR)
69
82
  // Some devices expose StrongBox but fail during generation.
70
83
  }
71
84
  }
@@ -89,6 +102,12 @@ internal class DPoPKeyStore(private val context: Context) {
89
102
  return privateKey != null && publicKey != null
90
103
  }
91
104
 
105
+ fun isStrongBoxAvailable(): Boolean = isStrongBoxEnabled()
106
+
107
+ fun getStrongBoxFallbackReason(alias: String): String? {
108
+ return metadataPrefs.getString(strongBoxFallbackKey(alias), null)
109
+ }
110
+
92
111
  fun getKeyInfo(alias: String): KeyStoreKeyInfo {
93
112
  val keyPair = getKeyPair(alias)
94
113
  val keyFactory = KeyFactory.getInstance(keyPair.privateKey.algorithm, KEYSTORE_PROVIDER)
@@ -165,4 +184,14 @@ internal class DPoPKeyStore(private val context: Context) {
165
184
  false
166
185
  }
167
186
  }
187
+
188
+ private fun storeStrongBoxFallbackReason(alias: String, reason: String) {
189
+ metadataPrefs.edit().putString(strongBoxFallbackKey(alias), reason).apply()
190
+ }
191
+
192
+ private fun clearStrongBoxFallbackReason(alias: String) {
193
+ metadataPrefs.edit().remove(strongBoxFallbackKey(alias)).apply()
194
+ }
195
+
196
+ private fun strongBoxFallbackKey(alias: String): String = "$META_STRONGBOX_FALLBACK_PREFIX$alias"
168
197
  }
@@ -1,4 +1,4 @@
1
- package com.dpop
1
+ package com.reactnativedpop
2
2
 
3
3
  import com.facebook.react.bridge.Arguments
4
4
  import com.facebook.react.bridge.Promise
@@ -8,8 +8,8 @@ import java.security.Signature
8
8
  import java.util.UUID
9
9
  import org.json.JSONObject
10
10
 
11
- class DpopModule(reactContext: ReactApplicationContext) :
12
- NativeDpopSpec(reactContext) {
11
+ class DPoPModule(reactContext: ReactApplicationContext) :
12
+ NativeReactNativeDPoPSpec(reactContext) {
13
13
  private val keyStore = DPoPKeyStore(reactContext)
14
14
 
15
15
  private fun resolveAlias(alias: String?): String {
@@ -70,26 +70,52 @@ class DpopModule(reactContext: ReactApplicationContext) :
70
70
  try {
71
71
  val effectiveAlias = resolveAlias(alias)
72
72
  if (!keyStore.hasKeyPair(effectiveAlias)) {
73
+ val fallbackReason = keyStore.getStrongBoxFallbackReason(effectiveAlias)
74
+ val hardwareAndroid = Arguments.createMap().apply {
75
+ putBoolean("strongBoxAvailable", keyStore.isStrongBoxAvailable())
76
+ putBoolean("strongBoxBacked", false)
77
+ if (fallbackReason != null) {
78
+ putString("strongBoxFallbackReason", fallbackReason)
79
+ } else {
80
+ putNull("strongBoxFallbackReason")
81
+ }
82
+ }
83
+ val hardware = Arguments.createMap().apply {
84
+ putMap("android", hardwareAndroid)
85
+ }
73
86
  val result = Arguments.createMap().apply {
74
87
  putString("alias", effectiveAlias)
75
88
  putBoolean("hasKeyPair", false)
89
+ putMap("hardware", hardware)
76
90
  }
77
91
  promise.resolve(result)
78
92
  return
79
93
  }
80
94
 
81
95
  val keyInfo = keyStore.getKeyInfo(effectiveAlias)
96
+ val fallbackReason = keyStore.getStrongBoxFallbackReason(effectiveAlias)
97
+ val hardwareAndroid = Arguments.createMap().apply {
98
+ putBoolean("strongBoxAvailable", keyInfo.strongBoxAvailable)
99
+ putBoolean("strongBoxBacked", keyInfo.strongBoxBacked)
100
+ if (keyInfo.securityLevel != null) {
101
+ putInt("securityLevel", keyInfo.securityLevel)
102
+ }
103
+ if (fallbackReason != null) {
104
+ putString("strongBoxFallbackReason", fallbackReason)
105
+ } else {
106
+ putNull("strongBoxFallbackReason")
107
+ }
108
+ }
109
+ val hardware = Arguments.createMap().apply {
110
+ putMap("android", hardwareAndroid)
111
+ }
82
112
  val result = Arguments.createMap().apply {
83
113
  putString("alias", keyInfo.alias)
84
114
  putString("algorithm", keyInfo.algorithm)
85
115
  putString("curve", keyInfo.curve)
86
116
  putBoolean("hasKeyPair", true)
87
117
  putBoolean("insideSecureHardware", keyInfo.insideSecureHardware)
88
- putBoolean("strongBoxAvailable", keyInfo.strongBoxAvailable)
89
- putBoolean("strongBoxBacked", keyInfo.strongBoxBacked)
90
- if (keyInfo.securityLevel != null) {
91
- putInt("securityLevel", keyInfo.securityLevel)
92
- }
118
+ putMap("hardware", hardware)
93
119
  }
94
120
  promise.resolve(result)
95
121
  } catch (e: Exception) {
@@ -297,6 +323,6 @@ class DpopModule(reactContext: ReactApplicationContext) :
297
323
 
298
324
  companion object {
299
325
  private const val DEFAULT_ALIAS = "react-native-dpop"
300
- const val NAME = NativeDpopSpec.NAME
326
+ const val NAME = NativeReactNativeDPoPSpec.NAME
301
327
  }
302
328
  }
@@ -1,4 +1,4 @@
1
- package com.dpop
1
+ package com.reactnativedpop
2
2
 
3
3
  import com.facebook.react.BaseReactPackage
4
4
  import com.facebook.react.bridge.NativeModule
@@ -7,10 +7,10 @@ import com.facebook.react.module.model.ReactModuleInfo
7
7
  import com.facebook.react.module.model.ReactModuleInfoProvider
8
8
  import java.util.HashMap
9
9
 
10
- class DpopPackage : BaseReactPackage() {
10
+ class DPoPPackage : BaseReactPackage() {
11
11
  override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
12
- return if (name == DpopModule.NAME) {
13
- DpopModule(reactContext)
12
+ return if (name == DPoPModule.NAME) {
13
+ DPoPModule(reactContext)
14
14
  } else {
15
15
  null
16
16
  }
@@ -18,9 +18,9 @@ class DpopPackage : BaseReactPackage() {
18
18
 
19
19
  override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
20
20
  mapOf(
21
- DpopModule.NAME to ReactModuleInfo(
22
- name = DpopModule.NAME,
23
- className = DpopModule.NAME,
21
+ DPoPModule.NAME to ReactModuleInfo(
22
+ name = DPoPModule.NAME,
23
+ className = DPoPModule.NAME,
24
24
  canOverrideExistingModule = false,
25
25
  needsEagerInit = false,
26
26
  isCxxModule = false,
@@ -1,4 +1,4 @@
1
- package com.dpop
1
+ package com.reactnativedpop
2
2
 
3
3
  import com.facebook.react.bridge.Arguments
4
4
  import com.facebook.react.bridge.ReadableArray
@@ -0,0 +1,134 @@
1
+ import Foundation
2
+ import Security
3
+
4
+ struct DPoPKeyPairReference {
5
+ let privateKey: SecKey
6
+ let publicKey: SecKey
7
+ }
8
+
9
+ struct DPoPKeyInfo {
10
+ let alias: String
11
+ let algorithm: String
12
+ let curve: String
13
+ let hasKeyPair: Bool
14
+ let insideSecureHardware: Bool
15
+ }
16
+
17
+ final class DPoPKeyStore {
18
+ private let secureEnclave = SecureEnclaveKeyStore()
19
+ private let keychain = KeychainKeyStore()
20
+ private let fallbackReasonDefaults = UserDefaults.standard
21
+ private let fallbackReasonPrefix = "react_native_dpop_secure_enclave_fallback_reason_"
22
+ private lazy var secureEnclaveAvailable = secureEnclave.isAvailable()
23
+
24
+ func generateKeyPair(alias: String) throws {
25
+ try deleteKeyPair(alias: alias)
26
+
27
+ do {
28
+ try secureEnclave.generateKeyPair(alias: alias)
29
+ clearSecureEnclaveFallbackReason(alias: alias)
30
+ } catch {
31
+ storeSecureEnclaveFallbackReason(alias: alias, reason: mapSecureEnclaveFallbackReason(error))
32
+ try keychain.generateKeyPair(alias: alias)
33
+ }
34
+ }
35
+
36
+ func deleteKeyPair(alias: String) throws {
37
+ try secureEnclave.deleteKeyPair(alias: alias)
38
+ try keychain.deleteKeyPair(alias: alias)
39
+ clearSecureEnclaveFallbackReason(alias: alias)
40
+ }
41
+
42
+ func hasKeyPair(alias: String) -> Bool {
43
+ secureEnclave.hasKeyPair(alias: alias) || keychain.hasKeyPair(alias: alias)
44
+ }
45
+
46
+ func getKeyPair(alias: String) throws -> DPoPKeyPairReference {
47
+ if secureEnclave.hasKeyPair(alias: alias) {
48
+ return DPoPKeyPairReference(
49
+ privateKey: try secureEnclave.getPrivateKey(alias: alias),
50
+ publicKey: try secureEnclave.getPublicKey(alias: alias)
51
+ )
52
+ }
53
+
54
+ if keychain.hasKeyPair(alias: alias) {
55
+ return DPoPKeyPairReference(
56
+ privateKey: try keychain.getPrivateKey(alias: alias),
57
+ publicKey: try keychain.getPublicKey(alias: alias)
58
+ )
59
+ }
60
+
61
+ throw DPoPError.keyNotFound(alias: alias)
62
+ }
63
+
64
+ func getKeyInfo(alias: String) -> DPoPKeyInfo {
65
+ if secureEnclave.hasKeyPair(alias: alias) {
66
+ return DPoPKeyInfo(
67
+ alias: alias,
68
+ algorithm: "EC",
69
+ curve: "P-256",
70
+ hasKeyPair: true,
71
+ insideSecureHardware: true
72
+ )
73
+ }
74
+
75
+ if keychain.hasKeyPair(alias: alias) {
76
+ return DPoPKeyInfo(
77
+ alias: alias,
78
+ algorithm: "EC",
79
+ curve: "P-256",
80
+ hasKeyPair: true,
81
+ insideSecureHardware: false
82
+ )
83
+ }
84
+
85
+ return DPoPKeyInfo(
86
+ alias: alias,
87
+ algorithm: "EC",
88
+ curve: "P-256",
89
+ hasKeyPair: false,
90
+ insideSecureHardware: false
91
+ )
92
+ }
93
+
94
+ func isHardwareBacked(alias: String) -> Bool {
95
+ secureEnclave.isHardwareBacked(alias: alias)
96
+ }
97
+
98
+ func isSecureEnclaveAvailable() -> Bool {
99
+ secureEnclaveAvailable
100
+ }
101
+
102
+ func getSecureEnclaveFallbackReason(alias: String) -> String? {
103
+ fallbackReasonDefaults.string(forKey: fallbackReasonKey(alias: alias))
104
+ }
105
+
106
+ private func storeSecureEnclaveFallbackReason(alias: String, reason: String) {
107
+ fallbackReasonDefaults.set(reason, forKey: fallbackReasonKey(alias: alias))
108
+ }
109
+
110
+ private func clearSecureEnclaveFallbackReason(alias: String) {
111
+ fallbackReasonDefaults.removeObject(forKey: fallbackReasonKey(alias: alias))
112
+ }
113
+
114
+ private func fallbackReasonKey(alias: String) -> String {
115
+ "\(fallbackReasonPrefix)\(alias)"
116
+ }
117
+
118
+ private func mapSecureEnclaveFallbackReason(_ error: Error) -> String {
119
+ let nsError = error as NSError
120
+
121
+ if nsError.domain == NSOSStatusErrorDomain {
122
+ switch nsError.code {
123
+ case Int(errSecNotAvailable), Int(errSecUnimplemented):
124
+ return "UNAVAILABLE"
125
+ case Int(errSecAuthFailed), Int(errSecInteractionNotAllowed), Int(errSecUserCanceled):
126
+ return "POLICY_REJECTED"
127
+ default:
128
+ return "PROVIDER_ERROR"
129
+ }
130
+ }
131
+
132
+ return "UNKNOWN"
133
+ }
134
+ }
@@ -0,0 +1,5 @@
1
+ #import <React/RCTBridgeModule.h>
2
+
3
+ @interface ReactNativeDPoP : NSObject <RCTBridgeModule>
4
+
5
+ @end