@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.
@@ -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
- This repo was originally scaffolded as a `create-react-native-library`
13
- turbo-module (`multiply()` native stub, Android/iOS native folders) on the
14
- assumption it would eventually bridge over the native Android/iOS consent
15
- engines (ADR-005). That native bridge was never built, and on review it
16
- isn't the right shape for this package anyway: `dpdpguard-android-sdk` and
17
- `dpdpguard-ios-sdk` are separate, independently-versioned native libraries
18
- (ADR-003), and bridging to them would tie every RN release to whatever
19
- native SDK versions happen to be vendored alongside it, for no benefit —
20
- none of this SDK's functionality needs on-device native code. The
21
- turbo-module scaffold (native folders, `NativeReactNative.ts`,
22
- `multiply()`) has been removed.
23
-
24
- What's implemented instead is the **HTTP client layer**: a typed
25
- `DpdpGuardClient` over DPDP Guard's public `/api/v1` (spec §4.2), following
26
- the same pattern as `dpdpguard-server-sdk`/`dpdpguard-js-sdk` — usable
27
- today from RN app code via plain `fetch`, with no native module involved.
28
- This is also the pattern `dpdpguard-flutter-sdk` follows, for consistency
29
- across both hybrid SDKs.
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 offline consent capture.** `dpdpguard-ios-sdk` implements the
47
- device-side Cryptographic Consent Artifact (`docs/specs/offline-consent-capture.md`
48
- §4) on top of Secure Enclave key storage and an encrypted local store.
49
- That needs native code, which this package deliberately doesn't have — and
50
- a software-only port would produce artifacts that don't prove what the
51
- spec's artifacts prove.
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