@dpdpguard/react-native 1.3.0 → 2.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/DpdpGuardReactNative.podspec +35 -0
- package/README.md +128 -24
- package/android/build.gradle +45 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/ai/dpdpguard/offline/DpdpGuardOfflineModule.kt +317 -0
- package/android/src/main/java/ai/dpdpguard/offline/DpdpGuardOfflinePackage.kt +33 -0
- package/ios/DpdpGuardOffline.h +18 -0
- package/ios/DpdpGuardOffline.mm +481 -0
- package/ios/DpdpGuardOfflineCrypto.swift +27 -0
- package/package.json +23 -4
- package/src/CONTRACT_VERSION +1 -1
- package/src/NativeDpdpGuardOffline.ts +64 -0
- package/src/generated/api-types.ts +1005 -224
- package/src/index.ts +33 -0
- package/src/offline/OfflineCaptureManager.ts +249 -0
- package/src/offline/captureArtifact.ts +184 -0
- package/src/offline/types.ts +134 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = "DpdpGuardReactNative"
|
|
7
|
+
s.version = package["version"]
|
|
8
|
+
s.summary = package["description"]
|
|
9
|
+
s.homepage = package["homepage"]
|
|
10
|
+
s.license = package["license"]
|
|
11
|
+
s.authors = package["author"]
|
|
12
|
+
|
|
13
|
+
s.platforms = { :ios => min_ios_version_supported }
|
|
14
|
+
s.source = { :git => "https://github.com/dpdp-guard-ai/dpdpguard-react-native-sdk.git", :tag => "v#{s.version}" }
|
|
15
|
+
|
|
16
|
+
s.source_files = "ios/**/*.{h,m,mm,swift}"
|
|
17
|
+
s.swift_version = "5.9"
|
|
18
|
+
|
|
19
|
+
# Secure Enclave key generation and ECDSA signing; CryptoKit (via a Swift
|
|
20
|
+
# shim, DpdpGuardOfflineCrypto) for the AES-GCM sealing of the
|
|
21
|
+
# pending-capture store -- CommonCrypto's public headers have no GCM API
|
|
22
|
+
# on current SDKs.
|
|
23
|
+
s.frameworks = "Security"
|
|
24
|
+
|
|
25
|
+
# Swift's ClangImporter builds this pod's umbrella module in plain
|
|
26
|
+
# Objective-C mode by default, but DpdpGuardOffline.h pulls in the
|
|
27
|
+
# TurboModule codegen header (DpdpGuardOfflineSpec.h), which is C++ and
|
|
28
|
+
# refuses to compile outside Objective-C++. objcxx interop mode makes
|
|
29
|
+
# Swift import it as such instead of tripping that guard.
|
|
30
|
+
s.pod_target_xcconfig = {
|
|
31
|
+
"SWIFT_OBJC_INTEROP_MODE" => "objcxx"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
install_modules_dependencies(s)
|
|
35
|
+
end
|
package/README.md
CHANGED
|
@@ -9,24 +9,31 @@ https://github.com/dpdp-guard-ai/dpdpbot/blob/main/docs/specs/mobile-server-sdk.
|
|
|
9
9
|
|
|
10
10
|
## What's here vs. what's not
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
`
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
12
|
+
Two layers, with different requirements:
|
|
13
|
+
|
|
14
|
+
**The HTTP client** — a typed `DpdpGuardClient` over DPDP Guard's public
|
|
15
|
+
`/api/v1` (spec §4.2), following the same pattern as
|
|
16
|
+
`dpdpguard-server-sdk`/`dpdpguard-js-sdk` and `dpdpguard-flutter-sdk`.
|
|
17
|
+
Plain `fetch`, no native module involved; usable from any JS runtime.
|
|
18
|
+
|
|
19
|
+
**Offline consent capture** — the device-side half of
|
|
20
|
+
`docs/specs/offline-consent-capture.md` §4, matching what
|
|
21
|
+
`dpdpguard-ios-sdk` ships natively. This *does* need native code: consent
|
|
22
|
+
captured away from the network is signed by a key held in the device's
|
|
23
|
+
secure element, and the pending queue is encrypted at rest. It requires
|
|
24
|
+
React Native's new architecture.
|
|
25
|
+
|
|
26
|
+
The split matters for what gets written twice. **The `dpdpcca/2`
|
|
27
|
+
canonicalization lives only in JavaScript** (`src/offline/captureArtifact.ts`),
|
|
28
|
+
tested against `@dpdpguard/contract`'s golden vectors. The native modules
|
|
29
|
+
under `ios/` and `android/` do key storage, signing, and encrypted
|
|
30
|
+
persistence only. A second canonicalization in Swift and a third in Kotlin
|
|
31
|
+
could drift from the server's, and drift there means a legitimate consent
|
|
32
|
+
quarantining as `signature_mismatch` on sync — so there is exactly one.
|
|
33
|
+
|
|
34
|
+
This is a reversal of the position taken in `1.0.0`, which removed the
|
|
35
|
+
`create-react-native-library` turbo-module scaffold on the reasoning that
|
|
36
|
+
nothing here needed on-device native code. Offline capture does.
|
|
30
37
|
|
|
31
38
|
**Deliberately excluded, for security, not by omission:**
|
|
32
39
|
|
|
@@ -43,12 +50,13 @@ across both hybrid SDKs.
|
|
|
43
50
|
client. The *unkeyed* half of the audit hash is available as
|
|
44
51
|
`canonicalAuditString()` (see [Audit-hash canonicalization](#audit-hash-canonicalization));
|
|
45
52
|
it needs no secret.
|
|
46
|
-
- **No
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
- **No direct sync of offline captures.**
|
|
54
|
+
`POST /api/v1/offline/captures` is declared `security: [apiKey]` — the
|
|
55
|
+
same org-scoped service key `brokerToken()` would need. So
|
|
56
|
+
`OfflineCaptureManager.syncPending()` takes a transport function you
|
|
57
|
+
supply, which in practice POSTs the batch to your own backend to be
|
|
58
|
+
forwarded with the key attached. Capture, signing, and queueing all
|
|
59
|
+
happen on-device; only the final hop is brokered.
|
|
52
60
|
|
|
53
61
|
## Usage
|
|
54
62
|
|
|
@@ -151,6 +159,102 @@ server-side, so this package stops at the canonical form — hand it to your
|
|
|
151
159
|
backend to sign, or use it to check which event a hash your backend
|
|
152
160
|
returned refers to.
|
|
153
161
|
|
|
162
|
+
## Offline consent capture
|
|
163
|
+
|
|
164
|
+
For field agents, POS terminals, and IVR gateways capturing consent away
|
|
165
|
+
from the network (`docs/specs/offline-consent-capture.md` §4). Requires the
|
|
166
|
+
new architecture — the native module is autolinked; no manual step beyond
|
|
167
|
+
`pod install`.
|
|
168
|
+
|
|
169
|
+
### 1. Enroll the device, once
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
import { OfflineCaptureManager } from '@dpdpguard/react-native';
|
|
173
|
+
|
|
174
|
+
const offline = new OfflineCaptureManager({
|
|
175
|
+
organizationId: 'org_abc123',
|
|
176
|
+
deviceId: 'device_pune_114', // the agentDevices row for this device
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const { publicKeySPKIBase64, hardwareBacked } = await offline.enroll();
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The private key is generated inside the Secure Enclave (iOS) or
|
|
183
|
+
StrongBox/TEE (Android) and never leaves the device — this SDK cannot
|
|
184
|
+
transmit it. Register `publicKeySPKIBase64` on `agentDevices` through the
|
|
185
|
+
fleet-enrollment console flow; until you do, every capture from this device
|
|
186
|
+
quarantines as `unknown_device`.
|
|
187
|
+
|
|
188
|
+
Check `hardwareBacked` before allowing capture in production. It is `false`
|
|
189
|
+
on Simulators, emulators, and hardware with no secure element, where the
|
|
190
|
+
key is only software-protected — the server cannot tell the difference from
|
|
191
|
+
the signature alone, so this is the only place that decision can be made.
|
|
192
|
+
|
|
193
|
+
### 2. Capture, with no connectivity
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
const session = await offline.capture({
|
|
197
|
+
noticeId: 'notice_v3',
|
|
198
|
+
noticeVersion: 3,
|
|
199
|
+
noticeLocale: 'hi', // the language actually rendered — s. 5(3) evidence
|
|
200
|
+
purposeGrants: [
|
|
201
|
+
{ purposeKey: 'kyc_verification', purposeLabel: 'KYC', category: 'essential', status: 'opt_in' },
|
|
202
|
+
{ purposeKey: 'marketing', purposeLabel: 'Marketing', category: 'non_essential', status: 'opt_out' },
|
|
203
|
+
],
|
|
204
|
+
dataTypes: ['pan', 'aadhaar'],
|
|
205
|
+
principalHandle: { kind: 'phone_hash', value: '9876543210' },
|
|
206
|
+
channel: 'agent_app',
|
|
207
|
+
});
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Nothing here touches the network. Pass every purpose the principal was
|
|
211
|
+
*shown*, opted in or out: proving someone was shown marketing and refused
|
|
212
|
+
is what distinguishes granular consent from a notice that quietly omitted
|
|
213
|
+
it.
|
|
214
|
+
|
|
215
|
+
### 3. Sync when connectivity returns
|
|
216
|
+
|
|
217
|
+
`POST /api/v1/offline/captures` requires an org-scoped service API key,
|
|
218
|
+
which must never ship in an app bundle — so you supply the transport, and
|
|
219
|
+
it goes through your own backend:
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
const results = await offline.syncPending(async (body) => {
|
|
223
|
+
const response = await fetch('https://my-backend.example/dpdp/offline-captures', {
|
|
224
|
+
method: 'POST',
|
|
225
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${myAppSessionToken}` },
|
|
226
|
+
body: JSON.stringify(body), // forward verbatim; your backend adds the DPDP Guard API key
|
|
227
|
+
});
|
|
228
|
+
return response.json(); // { results: [...] }
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
for (const result of results) {
|
|
232
|
+
if (result.status !== 'verified') {
|
|
233
|
+
console.warn(`${result.clientSessionId}: ${result.status} — ${result.reason}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**Partial success is normal.** A resolved promise does not mean every
|
|
239
|
+
capture committed — inspect each result. Batches are split at 200 sessions,
|
|
240
|
+
which is the endpoint's limit. Every acknowledged session is evicted from
|
|
241
|
+
the local store, including quarantined and rejected ones: those are
|
|
242
|
+
retained server-side as evidence (spec §4.4 — never silently dropped), so
|
|
243
|
+
keeping a local copy would only mean resubmitting them forever.
|
|
244
|
+
|
|
245
|
+
A `withdrawalToken` on a verified result is what makes the consent
|
|
246
|
+
withdrawable under s. 6(4) — print it on the slip or read it out.
|
|
247
|
+
|
|
248
|
+
### What the device signs
|
|
249
|
+
|
|
250
|
+
`capture()` builds the `dpdpcca/2` canonical string
|
|
251
|
+
(`conformance/audit-hash-spec.md`) and has the secure element sign its
|
|
252
|
+
UTF-8 bytes with ECDSA P-256 / SHA-256. The server recomputes that string
|
|
253
|
+
at sync and verifies it, so the canonicalization is exact: token lengths in
|
|
254
|
+
UTF-8 bytes, byte-wise sorts, explicit list counts. It is implemented once,
|
|
255
|
+
in JavaScript, and checked against the contract's golden vectors —
|
|
256
|
+
`canonicalCaptureString()` is exported if you need to re-derive it.
|
|
257
|
+
|
|
154
258
|
## Contract
|
|
155
259
|
|
|
156
260
|
Depends on
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
repositories {
|
|
3
|
+
google()
|
|
4
|
+
mavenCentral()
|
|
5
|
+
}
|
|
6
|
+
dependencies {
|
|
7
|
+
classpath "com.android.tools.build:gradle:8.7.2"
|
|
8
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
apply plugin: "com.android.library"
|
|
13
|
+
apply plugin: "org.jetbrains.kotlin.android"
|
|
14
|
+
apply plugin: "com.facebook.react"
|
|
15
|
+
|
|
16
|
+
android {
|
|
17
|
+
namespace "ai.dpdpguard.offline"
|
|
18
|
+
|
|
19
|
+
compileSdk 35
|
|
20
|
+
|
|
21
|
+
defaultConfig {
|
|
22
|
+
// KeyInfo.getSecurityLevel() and StrongBox both need modern APIs, but
|
|
23
|
+
// the module degrades rather than crashes below them: see
|
|
24
|
+
// DpdpGuardOfflineModule.isHardwareBacked.
|
|
25
|
+
minSdk 24
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
compileOptions {
|
|
29
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
30
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
kotlinOptions {
|
|
34
|
+
jvmTarget = "17"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
repositories {
|
|
39
|
+
google()
|
|
40
|
+
mavenCentral()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
dependencies {
|
|
44
|
+
implementation "com.facebook.react:react-android"
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
package ai.dpdpguard.offline
|
|
2
|
+
|
|
3
|
+
import android.os.Build
|
|
4
|
+
import android.security.keystore.KeyGenParameterSpec
|
|
5
|
+
import android.security.keystore.KeyInfo
|
|
6
|
+
import android.security.keystore.KeyProperties
|
|
7
|
+
import android.util.Base64
|
|
8
|
+
import com.facebook.react.bridge.Promise
|
|
9
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
10
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
11
|
+
import java.io.File
|
|
12
|
+
import java.security.KeyFactory
|
|
13
|
+
import java.security.KeyPairGenerator
|
|
14
|
+
import java.security.KeyStore
|
|
15
|
+
import java.security.PrivateKey
|
|
16
|
+
import java.security.Signature
|
|
17
|
+
import java.security.spec.ECGenParameterSpec
|
|
18
|
+
import javax.crypto.Cipher
|
|
19
|
+
import javax.crypto.KeyGenerator
|
|
20
|
+
import javax.crypto.SecretKey
|
|
21
|
+
import javax.crypto.spec.GCMParameterSpec
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Secure-element key storage, ECDSA P-256 signing, and an encrypted local
|
|
25
|
+
* queue for offline consent capture (docs/specs/offline-consent-capture.md
|
|
26
|
+
* §4).
|
|
27
|
+
*
|
|
28
|
+
* Deliberately narrow: this module never builds the `dpdpcca/2` canonical
|
|
29
|
+
* string. That lives in JavaScript (src/offline/captureArtifact.ts), tested
|
|
30
|
+
* against the contract's golden vectors, so there is exactly one
|
|
31
|
+
* implementation of the bytes a signature commits to — a second one here
|
|
32
|
+
* could drift, and drift means a legitimate consent quarantining as
|
|
33
|
+
* `signature_mismatch`.
|
|
34
|
+
*/
|
|
35
|
+
@ReactModule(name = DpdpGuardOfflineModule.NAME)
|
|
36
|
+
class DpdpGuardOfflineModule(reactContext: ReactApplicationContext) :
|
|
37
|
+
NativeDpdpGuardOfflineSpec(reactContext) {
|
|
38
|
+
|
|
39
|
+
companion object {
|
|
40
|
+
const val NAME = "DpdpGuardOffline"
|
|
41
|
+
|
|
42
|
+
private const val KEYSTORE = "AndroidKeyStore"
|
|
43
|
+
private const val SIGNING_KEY_ALIAS = "ai.dpdpguard.offline.signing-key"
|
|
44
|
+
private const val STORE_KEY_ALIAS = "ai.dpdpguard.offline.store-key"
|
|
45
|
+
private const val STORE_DIRECTORY = "dpdpguard-offline-captures"
|
|
46
|
+
private const val SESSION_EXTENSION = ".dpdpcap"
|
|
47
|
+
|
|
48
|
+
/** AES-GCM nonce length, in bytes. */
|
|
49
|
+
private const val NONCE_LENGTH = 12
|
|
50
|
+
private const val TAG_BITS = 128
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
override fun getName(): String = NAME
|
|
54
|
+
|
|
55
|
+
private val keyStore: KeyStore by lazy {
|
|
56
|
+
KeyStore.getInstance(KEYSTORE).apply { load(null) }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private val storeDirectory: File by lazy {
|
|
60
|
+
// filesDir, not cacheDir or external storage: pending captures hold PII
|
|
61
|
+
// and are evidence of a legal event, so they must not be evictable by
|
|
62
|
+
// the OS or readable by another app.
|
|
63
|
+
File(reactApplicationContext.filesDir, STORE_DIRECTORY).apply { mkdirs() }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Signing key ---------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
private fun signingKeyEntry(): KeyStore.PrivateKeyEntry {
|
|
69
|
+
(keyStore.getEntry(SIGNING_KEY_ALIAS, null) as? KeyStore.PrivateKeyEntry)?.let {
|
|
70
|
+
return it
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
val spec = KeyGenParameterSpec.Builder(
|
|
74
|
+
SIGNING_KEY_ALIAS,
|
|
75
|
+
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY,
|
|
76
|
+
)
|
|
77
|
+
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
|
|
78
|
+
.setDigests(KeyProperties.DIGEST_SHA256)
|
|
79
|
+
// No setUserAuthenticationRequired: a field agent captures consent for
|
|
80
|
+
// someone else, often on a shared device, so gating each signature on
|
|
81
|
+
// the agent's biometric would block the capture the spec requires to
|
|
82
|
+
// work at zero bars and with no interaction beyond the notice itself.
|
|
83
|
+
.apply {
|
|
84
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
85
|
+
// StrongBox where the hardware has it; fall through below if not.
|
|
86
|
+
setIsStrongBoxBacked(true)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
.build()
|
|
90
|
+
|
|
91
|
+
val generator = KeyPairGenerator.getInstance(
|
|
92
|
+
KeyProperties.KEY_ALGORITHM_EC,
|
|
93
|
+
KEYSTORE,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
generator.initialize(spec)
|
|
98
|
+
generator.generateKeyPair()
|
|
99
|
+
} catch (strongBoxUnavailable: Exception) {
|
|
100
|
+
// StrongBoxUnavailableException on devices without a dedicated secure
|
|
101
|
+
// element. Retry against the TEE: a TEE-held key is still non-
|
|
102
|
+
// exportable, and isHardwareBacked() reports what was actually used.
|
|
103
|
+
val fallback = KeyGenParameterSpec.Builder(
|
|
104
|
+
SIGNING_KEY_ALIAS,
|
|
105
|
+
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY,
|
|
106
|
+
)
|
|
107
|
+
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
|
|
108
|
+
.setDigests(KeyProperties.DIGEST_SHA256)
|
|
109
|
+
.build()
|
|
110
|
+
generator.initialize(fallback)
|
|
111
|
+
generator.generateKeyPair()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return keyStore.getEntry(SIGNING_KEY_ALIAS, null) as KeyStore.PrivateKeyEntry
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
override fun enrollDevice(promise: Promise) {
|
|
118
|
+
try {
|
|
119
|
+
val entry = signingKeyEntry()
|
|
120
|
+
// getEncoded() on an EC public key is already SPKI DER, exactly what
|
|
121
|
+
// agentDevices.publicKey stores.
|
|
122
|
+
val spki = entry.certificate.publicKey.encoded
|
|
123
|
+
promise.resolve(Base64.encodeToString(spki, Base64.NO_WRAP))
|
|
124
|
+
} catch (error: Exception) {
|
|
125
|
+
promise.reject("enroll_failed", "Could not create the device signing key.", error)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
override fun getPublicKey(promise: Promise) {
|
|
130
|
+
try {
|
|
131
|
+
val entry = keyStore.getEntry(SIGNING_KEY_ALIAS, null) as? KeyStore.PrivateKeyEntry
|
|
132
|
+
if (entry == null) {
|
|
133
|
+
promise.resolve(null)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
val spki = entry.certificate.publicKey.encoded
|
|
137
|
+
promise.resolve(Base64.encodeToString(spki, Base64.NO_WRAP))
|
|
138
|
+
} catch (error: Exception) {
|
|
139
|
+
promise.reject("keystore_failed", "Could not read the device signing key.", error)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
override fun isHardwareBacked(promise: Promise) {
|
|
144
|
+
try {
|
|
145
|
+
val entry = keyStore.getEntry(SIGNING_KEY_ALIAS, null) as? KeyStore.PrivateKeyEntry
|
|
146
|
+
if (entry == null) {
|
|
147
|
+
promise.resolve(false)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
val privateKey: PrivateKey = entry.privateKey
|
|
152
|
+
val factory = KeyFactory.getInstance(privateKey.algorithm, KEYSTORE)
|
|
153
|
+
val info = factory.getKeySpec(privateKey, KeyInfo::class.java)
|
|
154
|
+
|
|
155
|
+
val backed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
156
|
+
info.securityLevel == KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT ||
|
|
157
|
+
info.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX
|
|
158
|
+
} else {
|
|
159
|
+
@Suppress("DEPRECATION")
|
|
160
|
+
info.isInsideSecureHardware
|
|
161
|
+
}
|
|
162
|
+
promise.resolve(backed)
|
|
163
|
+
} catch (error: Exception) {
|
|
164
|
+
promise.resolve(false)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
override fun sign(canonical: String, promise: Promise) {
|
|
169
|
+
try {
|
|
170
|
+
val entry = signingKeyEntry()
|
|
171
|
+
val signature = Signature.getInstance("SHA256withECDSA").apply {
|
|
172
|
+
initSign(entry.privateKey)
|
|
173
|
+
update(canonical.toByteArray(Charsets.UTF_8))
|
|
174
|
+
}
|
|
175
|
+
val der = signature.sign()
|
|
176
|
+
val p1363 = p1363FromDer(der)
|
|
177
|
+
?: throw IllegalStateException("Signature was not valid X9.62 DER.")
|
|
178
|
+
promise.resolve(Base64.encodeToString(p1363, Base64.NO_WRAP))
|
|
179
|
+
} catch (error: Exception) {
|
|
180
|
+
promise.reject("sign_failed", "The device refused to sign the capture.", error)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Converts the X9.62 DER signature the JCA returns into the raw
|
|
186
|
+
* IEEE-P1363 `r || s` pair the server verifies. Both integers are
|
|
187
|
+
* left-padded to exactly 32 bytes: DER strips leading zeroes and may add
|
|
188
|
+
* a padding byte, so neither is a fixed width on the wire.
|
|
189
|
+
*/
|
|
190
|
+
private fun p1363FromDer(der: ByteArray): ByteArray? {
|
|
191
|
+
if (der.size < 8 || der[0] != 0x30.toByte()) return null
|
|
192
|
+
|
|
193
|
+
var index = 1
|
|
194
|
+
// Skip the (possibly long-form) sequence length.
|
|
195
|
+
index += if (der[index].toInt() and 0x80 != 0) {
|
|
196
|
+
1 + (der[index].toInt() and 0x7f)
|
|
197
|
+
} else {
|
|
198
|
+
1
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
val out = ByteArray(64)
|
|
202
|
+
for (half in 0..1) {
|
|
203
|
+
if (index >= der.size || der[index] != 0x02.toByte()) return null
|
|
204
|
+
index++
|
|
205
|
+
if (index >= der.size) return null
|
|
206
|
+
|
|
207
|
+
var intLength = der[index].toInt() and 0xff
|
|
208
|
+
index++
|
|
209
|
+
if (intLength == 0 || index + intLength > der.size) return null
|
|
210
|
+
|
|
211
|
+
var start = index
|
|
212
|
+
index += intLength // Advance by the declared length, before trimming.
|
|
213
|
+
|
|
214
|
+
while (intLength > 1 && der[start] == 0x00.toByte()) {
|
|
215
|
+
start++
|
|
216
|
+
intLength--
|
|
217
|
+
}
|
|
218
|
+
if (intLength > 32) return null
|
|
219
|
+
|
|
220
|
+
System.arraycopy(der, start, out, half * 32 + (32 - intLength), intLength)
|
|
221
|
+
}
|
|
222
|
+
return out
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- Encrypted store -----------------------------------------------------
|
|
226
|
+
|
|
227
|
+
private fun storeKey(): SecretKey {
|
|
228
|
+
(keyStore.getEntry(STORE_KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let {
|
|
229
|
+
return it.secretKey
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
|
|
233
|
+
generator.init(
|
|
234
|
+
KeyGenParameterSpec.Builder(
|
|
235
|
+
STORE_KEY_ALIAS,
|
|
236
|
+
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
|
237
|
+
)
|
|
238
|
+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
239
|
+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
240
|
+
.setKeySize(256)
|
|
241
|
+
.build(),
|
|
242
|
+
)
|
|
243
|
+
return generator.generateKey()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** On-disk layout is `nonce(12) || ciphertext || tag(16)`. */
|
|
247
|
+
private fun seal(plaintext: ByteArray): ByteArray {
|
|
248
|
+
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
|
249
|
+
cipher.init(Cipher.ENCRYPT_MODE, storeKey())
|
|
250
|
+
val sealed = cipher.doFinal(plaintext)
|
|
251
|
+
return cipher.iv + sealed
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private fun open(sealed: ByteArray): ByteArray? = try {
|
|
255
|
+
if (sealed.size <= NONCE_LENGTH) {
|
|
256
|
+
null
|
|
257
|
+
} else {
|
|
258
|
+
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
|
259
|
+
cipher.init(
|
|
260
|
+
Cipher.DECRYPT_MODE,
|
|
261
|
+
storeKey(),
|
|
262
|
+
GCMParameterSpec(TAG_BITS, sealed, 0, NONCE_LENGTH),
|
|
263
|
+
)
|
|
264
|
+
cipher.doFinal(sealed, NONCE_LENGTH, sealed.size - NONCE_LENGTH)
|
|
265
|
+
}
|
|
266
|
+
} catch (corrupt: Exception) {
|
|
267
|
+
null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** clientSessionId is client-supplied; keep it out of the path. */
|
|
271
|
+
private fun fileFor(clientSessionId: String): File {
|
|
272
|
+
val safeName = Base64.encodeToString(
|
|
273
|
+
clientSessionId.toByteArray(Charsets.UTF_8),
|
|
274
|
+
Base64.NO_WRAP or Base64.URL_SAFE or Base64.NO_PADDING,
|
|
275
|
+
)
|
|
276
|
+
return File(storeDirectory, safeName + SESSION_EXTENSION)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
override fun saveSession(clientSessionId: String, sessionJson: String, promise: Promise) {
|
|
280
|
+
try {
|
|
281
|
+
fileFor(clientSessionId).writeBytes(seal(sessionJson.toByteArray(Charsets.UTF_8)))
|
|
282
|
+
promise.resolve(null)
|
|
283
|
+
} catch (error: Exception) {
|
|
284
|
+
promise.reject("store_failed", "Could not persist the capture.", error)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
override fun loadPendingSessions(promise: Promise) {
|
|
289
|
+
try {
|
|
290
|
+
val sessions = com.facebook.react.bridge.Arguments.createArray()
|
|
291
|
+
storeDirectory.listFiles()
|
|
292
|
+
?.filter { it.name.endsWith(SESSION_EXTENSION) }
|
|
293
|
+
?.forEach { file ->
|
|
294
|
+
// A file that will not open is corrupt or was written under a key
|
|
295
|
+
// that no longer exists. Skip it rather than failing the whole
|
|
296
|
+
// drain — the sessions that can still sync are evidence too.
|
|
297
|
+
val plaintext = open(file.readBytes()) ?: return@forEach
|
|
298
|
+
sessions.pushString(String(plaintext, Charsets.UTF_8))
|
|
299
|
+
}
|
|
300
|
+
promise.resolve(sessions)
|
|
301
|
+
} catch (error: Exception) {
|
|
302
|
+
promise.reject("store_failed", "Could not read pending captures.", error)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
override fun removeSession(clientSessionId: String, promise: Promise) {
|
|
307
|
+
fileFor(clientSessionId).delete()
|
|
308
|
+
promise.resolve(null)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
override fun removeAllSessions(promise: Promise) {
|
|
312
|
+
storeDirectory.listFiles()
|
|
313
|
+
?.filter { it.name.endsWith(SESSION_EXTENSION) }
|
|
314
|
+
?.forEach { it.delete() }
|
|
315
|
+
promise.resolve(null)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package ai.dpdpguard.offline
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.BaseReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfo
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
8
|
+
|
|
9
|
+
/** Autolinked entry point for [DpdpGuardOfflineModule]. */
|
|
10
|
+
class DpdpGuardOfflinePackage : BaseReactPackage() {
|
|
11
|
+
|
|
12
|
+
override fun getModule(
|
|
13
|
+
name: String,
|
|
14
|
+
reactContext: ReactApplicationContext,
|
|
15
|
+
): NativeModule? = if (name == DpdpGuardOfflineModule.NAME) {
|
|
16
|
+
DpdpGuardOfflineModule(reactContext)
|
|
17
|
+
} else {
|
|
18
|
+
null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider {
|
|
22
|
+
mapOf(
|
|
23
|
+
DpdpGuardOfflineModule.NAME to ReactModuleInfo(
|
|
24
|
+
DpdpGuardOfflineModule.NAME,
|
|
25
|
+
DpdpGuardOfflineModule.NAME,
|
|
26
|
+
false, // canOverrideExistingModule
|
|
27
|
+
false, // needsEagerInit
|
|
28
|
+
false, // isCxxModule
|
|
29
|
+
true, // isTurboModule
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#import <DpdpGuardOfflineSpec/DpdpGuardOfflineSpec.h>
|
|
2
|
+
|
|
3
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Secure-element key storage, ECDSA P-256 signing, and an encrypted local
|
|
7
|
+
* queue for offline consent capture
|
|
8
|
+
* (docs/specs/offline-consent-capture.md §4).
|
|
9
|
+
*
|
|
10
|
+
* Deliberately narrow: this module never builds the `dpdpcca/2` canonical
|
|
11
|
+
* string. That lives in JavaScript (src/offline/captureArtifact.ts), tested
|
|
12
|
+
* against the contract's golden vectors, so there is exactly one
|
|
13
|
+
* implementation of the bytes a signature commits to.
|
|
14
|
+
*/
|
|
15
|
+
@interface DpdpGuardOffline : NSObject <NativeDpdpGuardOfflineSpec>
|
|
16
|
+
@end
|
|
17
|
+
|
|
18
|
+
NS_ASSUME_NONNULL_END
|