@dpdpguard/react-native 1.2.0 → 2.0.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,481 @@
1
+ #import "DpdpGuardOffline.h"
2
+
3
+ #import <Security/Security.h>
4
+
5
+ #import "DpdpGuardReactNative-Swift.h"
6
+
7
+ static NSString *const kSigningKeyTag = @"ai.dpdpguard.offline.signing-key";
8
+ static NSString *const kStoreKeyAccount = @"ai.dpdpguard.offline.store-key";
9
+ static NSString *const kStoreKeyService = @"ai.dpdpguard.offline";
10
+ static NSString *const kErrorDomain = @"DpdpGuardOffline";
11
+ static NSString *const kSessionFileExtension = @"dpdpcap";
12
+
13
+ /**
14
+ * The fixed ASN.1 SubjectPublicKeyInfo header for an uncompressed
15
+ * secp256r1 public key. `SecKeyCopyExternalRepresentation` hands back the
16
+ * bare ANSI X9.63 point (0x04 || X || Y), but `agentDevices.publicKey`
17
+ * stores base64 SPKI DER, so the header is prepended here rather than
18
+ * being reconstructed by every caller.
19
+ */
20
+ static const uint8_t kP256SPKIHeader[] = {
21
+ 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02,
22
+ 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03,
23
+ 0x42, 0x00};
24
+
25
+ @implementation DpdpGuardOffline {
26
+ BOOL _hardwareBacked;
27
+ }
28
+
29
+ RCT_EXPORT_MODULE()
30
+
31
+ - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
32
+ (const facebook::react::ObjCTurboModule::InitParams &)params {
33
+ return std::make_shared<facebook::react::NativeDpdpGuardOfflineSpecJSI>(params);
34
+ }
35
+
36
+ #pragma mark - Errors
37
+
38
+ static NSError *DpdpError(NSInteger code, NSString *message) {
39
+ return [NSError errorWithDomain:kErrorDomain
40
+ code:code
41
+ userInfo:@{NSLocalizedDescriptionKey : message}];
42
+ }
43
+
44
+ #pragma mark - Signing key
45
+
46
+ /** The Secure Enclave key if this device has one, else a Keychain key. */
47
+ - (SecKeyRef)copySigningKeyCreatingIfNeeded:(NSError **)error {
48
+ NSData *tag = [kSigningKeyTag dataUsingEncoding:NSUTF8StringEncoding];
49
+
50
+ NSDictionary *query = @{
51
+ (id)kSecClass : (id)kSecClassKey,
52
+ (id)kSecAttrApplicationTag : tag,
53
+ (id)kSecAttrKeyType : (id)kSecAttrKeyTypeECSECPrimeRandom,
54
+ (id)kSecReturnRef : @YES,
55
+ };
56
+
57
+ SecKeyRef existing = NULL;
58
+ OSStatus status =
59
+ SecItemCopyMatching((__bridge CFDictionaryRef)query, (CFTypeRef *)&existing);
60
+ if (status == errSecSuccess && existing != NULL) {
61
+ return existing;
62
+ }
63
+
64
+ // Secure Enclave where available; a Keychain-held software key otherwise
65
+ // (Simulator, and hardware without an enclave). The distinction is
66
+ // reported by isHardwareBacked so a host app can refuse offline capture
67
+ // on a device whose key is only software-protected — the server cannot
68
+ // tell the two apart from a signature.
69
+ BOOL useSecureEnclave = NO;
70
+ #if !TARGET_OS_SIMULATOR
71
+ useSecureEnclave = YES;
72
+ #endif
73
+
74
+ CFErrorRef accessError = NULL;
75
+ SecAccessControlRef access = SecAccessControlCreateWithFlags(
76
+ kCFAllocatorDefault, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
77
+ useSecureEnclave ? kSecAccessControlPrivateKeyUsage : 0, &accessError);
78
+ if (access == NULL) {
79
+ if (error != NULL) {
80
+ *error = DpdpError(1, @"Could not create key access control.");
81
+ }
82
+ if (accessError) CFRelease(accessError);
83
+ return NULL;
84
+ }
85
+
86
+ NSMutableDictionary *keyAttributes = [@{
87
+ (id)kSecAttrKeyType : (id)kSecAttrKeyTypeECSECPrimeRandom,
88
+ (id)kSecAttrKeySizeInBits : @256,
89
+ (id)kSecPrivateKeyAttrs : @{
90
+ (id)kSecAttrIsPermanent : @YES,
91
+ (id)kSecAttrApplicationTag : tag,
92
+ (id)kSecAttrAccessControl : (__bridge id)access,
93
+ },
94
+ } mutableCopy];
95
+
96
+ if (useSecureEnclave) {
97
+ keyAttributes[(id)kSecAttrTokenID] = (id)kSecAttrTokenIDSecureEnclave;
98
+ }
99
+
100
+ CFErrorRef createError = NULL;
101
+ SecKeyRef privateKey = SecKeyCreateRandomKey(
102
+ (__bridge CFDictionaryRef)keyAttributes, &createError);
103
+ CFRelease(access);
104
+
105
+ if (privateKey == NULL) {
106
+ // Fall back to a software key if the enclave refused (some hardware
107
+ // and configurations do). Better a software-backed capture, correctly
108
+ // reported as such, than no capture at all.
109
+ if (useSecureEnclave) {
110
+ if (createError) CFRelease(createError);
111
+ keyAttributes[(id)kSecAttrTokenID] = nil;
112
+ createError = NULL;
113
+ privateKey = SecKeyCreateRandomKey(
114
+ (__bridge CFDictionaryRef)keyAttributes, &createError);
115
+ }
116
+ if (privateKey == NULL) {
117
+ if (error != NULL) {
118
+ *error = DpdpError(2, @"Could not create the device signing key.");
119
+ }
120
+ if (createError) CFRelease(createError);
121
+ return NULL;
122
+ }
123
+ _hardwareBacked = NO;
124
+ } else {
125
+ _hardwareBacked = useSecureEnclave;
126
+ }
127
+
128
+ if (createError) CFRelease(createError);
129
+ return privateKey;
130
+ }
131
+
132
+ - (NSString *)copyPublicKeySPKIBase64:(SecKeyRef)privateKey error:(NSError **)error {
133
+ SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey);
134
+ if (publicKey == NULL) {
135
+ if (error != NULL) *error = DpdpError(3, @"Could not derive the public key.");
136
+ return nil;
137
+ }
138
+
139
+ CFErrorRef exportError = NULL;
140
+ CFDataRef raw = SecKeyCopyExternalRepresentation(publicKey, &exportError);
141
+ CFRelease(publicKey);
142
+ if (raw == NULL) {
143
+ if (error != NULL) *error = DpdpError(4, @"Could not export the public key.");
144
+ if (exportError) CFRelease(exportError);
145
+ return nil;
146
+ }
147
+
148
+ NSMutableData *spki =
149
+ [NSMutableData dataWithBytes:kP256SPKIHeader length:sizeof(kP256SPKIHeader)];
150
+ [spki appendData:(__bridge NSData *)raw];
151
+ CFRelease(raw);
152
+
153
+ return [spki base64EncodedStringWithOptions:0];
154
+ }
155
+
156
+ #pragma mark - Signature encoding
157
+
158
+ /**
159
+ * Converts the X9.62 DER signature Security.framework returns into the raw
160
+ * IEEE-P1363 `r || s` pair the server verifies. Both integers are
161
+ * left-padded to exactly 32 bytes; DER strips leading zeroes and may add a
162
+ * padding byte, so neither is a fixed width on the wire.
163
+ */
164
+ static NSData *P1363FromDER(NSData *der) {
165
+ const uint8_t *bytes = (const uint8_t *)der.bytes;
166
+ NSUInteger length = der.length;
167
+ if (length < 8 || bytes[0] != 0x30) return nil;
168
+
169
+ NSUInteger index = 1;
170
+ // Skip the (possibly long-form) sequence length.
171
+ if (bytes[index] & 0x80) {
172
+ NSUInteger lengthBytes = bytes[index] & 0x7f;
173
+ index += 1 + lengthBytes;
174
+ } else {
175
+ index += 1;
176
+ }
177
+
178
+ uint8_t out[64] = {0};
179
+ for (int half = 0; half < 2; half++) {
180
+ if (index >= length || bytes[index] != 0x02) return nil;
181
+ index++;
182
+ if (index >= length) return nil;
183
+
184
+ NSUInteger intLength = bytes[index];
185
+ index++;
186
+ if (intLength == 0 || index + intLength > length) return nil;
187
+
188
+ const uint8_t *value = bytes + index;
189
+ index += intLength; // Advance by the *declared* length, before trimming.
190
+
191
+ // DER encodes a leading 0x00 to keep the integer positive; P1363 wants
192
+ // a fixed 32-byte big-endian field, so strip it and left-pad instead.
193
+ while (intLength > 1 && *value == 0x00) {
194
+ value++;
195
+ intLength--;
196
+ }
197
+ if (intLength > 32) return nil;
198
+
199
+ memcpy(out + half * 32 + (32 - intLength), value, intLength);
200
+ }
201
+
202
+ return [NSData dataWithBytes:out length:sizeof(out)];
203
+ }
204
+
205
+ #pragma mark - Encrypted store
206
+
207
+ - (NSURL *)storeDirectory:(NSError **)error {
208
+ NSURL *support = [[NSFileManager defaultManager] URLForDirectory:NSApplicationSupportDirectory
209
+ inDomain:NSUserDomainMask
210
+ appropriateForURL:nil
211
+ create:YES
212
+ error:error];
213
+ if (support == nil) return nil;
214
+
215
+ NSURL *directory = [support URLByAppendingPathComponent:@"DpdpGuardOfflineCaptures"
216
+ isDirectory:YES];
217
+ if (![[NSFileManager defaultManager] fileExistsAtPath:directory.path]) {
218
+ if (![[NSFileManager defaultManager] createDirectoryAtURL:directory
219
+ withIntermediateDirectories:YES
220
+ attributes:nil
221
+ error:error]) {
222
+ return nil;
223
+ }
224
+ }
225
+
226
+ // Pending captures hold PII and are evidence of a legal event, not
227
+ // cacheable state — they must not ride along into a device or iCloud
228
+ // backup where their retention window no longer applies.
229
+ NSURL *mutableDirectory = directory;
230
+ [mutableDirectory setResourceValue:@YES
231
+ forKey:NSURLIsExcludedFromBackupKey
232
+ error:NULL];
233
+
234
+ return directory;
235
+ }
236
+
237
+ /** A device-only AES-256 key for the store, created on first use. */
238
+ - (NSData *)storeKey:(NSError **)error {
239
+ NSDictionary *query = @{
240
+ (id)kSecClass : (id)kSecClassGenericPassword,
241
+ (id)kSecAttrService : kStoreKeyService,
242
+ (id)kSecAttrAccount : kStoreKeyAccount,
243
+ (id)kSecReturnData : @YES,
244
+ };
245
+
246
+ CFDataRef existing = NULL;
247
+ OSStatus status =
248
+ SecItemCopyMatching((__bridge CFDictionaryRef)query, (CFTypeRef *)&existing);
249
+ if (status == errSecSuccess && existing != NULL) {
250
+ return (__bridge_transfer NSData *)existing;
251
+ }
252
+
253
+ NSMutableData *key = [NSMutableData dataWithLength:32];
254
+ if (SecRandomCopyBytes(kSecRandomDefault, 32, key.mutableBytes) != errSecSuccess) {
255
+ if (error != NULL) *error = DpdpError(5, @"Could not generate a store key.");
256
+ return nil;
257
+ }
258
+
259
+ NSDictionary *add = @{
260
+ (id)kSecClass : (id)kSecClassGenericPassword,
261
+ (id)kSecAttrService : kStoreKeyService,
262
+ (id)kSecAttrAccount : kStoreKeyAccount,
263
+ (id)kSecValueData : key,
264
+ (id)kSecAttrAccessible : (id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
265
+ };
266
+ if (SecItemAdd((__bridge CFDictionaryRef)add, NULL) != errSecSuccess) {
267
+ if (error != NULL) *error = DpdpError(6, @"Could not persist the store key.");
268
+ return nil;
269
+ }
270
+ return key;
271
+ }
272
+
273
+ /**
274
+ * AES-256-GCM via CryptoKit (DpdpGuardOfflineCrypto.swift — CommonCrypto has
275
+ * no public GCM API). Layout on disk is `nonce(12) || ciphertext || tag(16)`.
276
+ */
277
+ static NSData *SealSession(NSData *plaintext, NSData *key) {
278
+ return [DpdpGuardOfflineCrypto seal:plaintext key:key];
279
+ }
280
+
281
+ static NSData *OpenSession(NSData *sealed, NSData *key) {
282
+ return [DpdpGuardOfflineCrypto open:sealed key:key];
283
+ }
284
+
285
+ - (NSURL *)fileForSession:(NSString *)clientSessionId error:(NSError **)error {
286
+ NSURL *directory = [self storeDirectory:error];
287
+ if (directory == nil) return nil;
288
+
289
+ // clientSessionId is a client-supplied string; keep it out of the path.
290
+ NSString *safeName = [[clientSessionId dataUsingEncoding:NSUTF8StringEncoding]
291
+ base64EncodedStringWithOptions:0];
292
+ safeName = [[safeName stringByReplacingOccurrencesOfString:@"/" withString:@"_"]
293
+ stringByReplacingOccurrencesOfString:@"+"
294
+ withString:@"-"];
295
+
296
+ return [directory URLByAppendingPathComponent:
297
+ [NSString stringWithFormat:@"%@.%@", safeName,
298
+ kSessionFileExtension]];
299
+ }
300
+
301
+ #pragma mark - NativeDpdpGuardOfflineSpec
302
+
303
+ - (void)enrollDevice:(RCTPromiseResolveBlock)resolve
304
+ reject:(RCTPromiseRejectBlock)reject {
305
+ NSError *error = nil;
306
+ SecKeyRef key = [self copySigningKeyCreatingIfNeeded:&error];
307
+ if (key == NULL) {
308
+ reject(@"enroll_failed", error.localizedDescription, error);
309
+ return;
310
+ }
311
+
312
+ NSString *publicKey = [self copyPublicKeySPKIBase64:key error:&error];
313
+ CFRelease(key);
314
+
315
+ if (publicKey == nil) {
316
+ reject(@"enroll_failed", error.localizedDescription, error);
317
+ return;
318
+ }
319
+ resolve(publicKey);
320
+ }
321
+
322
+ - (void)getPublicKey:(RCTPromiseResolveBlock)resolve
323
+ reject:(RCTPromiseRejectBlock)reject {
324
+ NSData *tag = [kSigningKeyTag dataUsingEncoding:NSUTF8StringEncoding];
325
+ NSDictionary *query = @{
326
+ (id)kSecClass : (id)kSecClassKey,
327
+ (id)kSecAttrApplicationTag : tag,
328
+ (id)kSecAttrKeyType : (id)kSecAttrKeyTypeECSECPrimeRandom,
329
+ (id)kSecReturnRef : @YES,
330
+ };
331
+
332
+ SecKeyRef key = NULL;
333
+ if (SecItemCopyMatching((__bridge CFDictionaryRef)query, (CFTypeRef *)&key) !=
334
+ errSecSuccess ||
335
+ key == NULL) {
336
+ resolve(nil);
337
+ return;
338
+ }
339
+
340
+ NSError *error = nil;
341
+ NSString *publicKey = [self copyPublicKeySPKIBase64:key error:&error];
342
+ CFRelease(key);
343
+ resolve(publicKey);
344
+ }
345
+
346
+ - (void)isHardwareBacked:(RCTPromiseResolveBlock)resolve
347
+ reject:(RCTPromiseRejectBlock)reject {
348
+ #if TARGET_OS_SIMULATOR
349
+ resolve(@NO);
350
+ #else
351
+ resolve(@(_hardwareBacked));
352
+ #endif
353
+ }
354
+
355
+ - (void)sign:(NSString *)canonical
356
+ resolve:(RCTPromiseResolveBlock)resolve
357
+ reject:(RCTPromiseRejectBlock)reject {
358
+ NSError *error = nil;
359
+ SecKeyRef key = [self copySigningKeyCreatingIfNeeded:&error];
360
+ if (key == NULL) {
361
+ reject(@"sign_failed", error.localizedDescription, error);
362
+ return;
363
+ }
364
+
365
+ NSData *message = [canonical dataUsingEncoding:NSUTF8StringEncoding];
366
+ CFErrorRef signError = NULL;
367
+ CFDataRef der = SecKeyCreateSignature(
368
+ key, kSecKeyAlgorithmECDSASignatureMessageX962SHA256,
369
+ (__bridge CFDataRef)message, &signError);
370
+ CFRelease(key);
371
+
372
+ if (der == NULL) {
373
+ reject(@"sign_failed", @"The device refused to sign the capture.", nil);
374
+ if (signError) CFRelease(signError);
375
+ return;
376
+ }
377
+
378
+ NSData *p1363 = P1363FromDER((__bridge NSData *)der);
379
+ CFRelease(der);
380
+
381
+ if (p1363 == nil) {
382
+ reject(@"sign_failed", @"Could not re-encode the signature as IEEE-P1363.", nil);
383
+ return;
384
+ }
385
+ resolve([p1363 base64EncodedStringWithOptions:0]);
386
+ }
387
+
388
+ - (void)saveSession:(NSString *)clientSessionId
389
+ sessionJson:(NSString *)sessionJson
390
+ resolve:(RCTPromiseResolveBlock)resolve
391
+ reject:(RCTPromiseRejectBlock)reject {
392
+ NSError *error = nil;
393
+ NSData *key = [self storeKey:&error];
394
+ NSURL *file = [self fileForSession:clientSessionId error:&error];
395
+ if (key == nil || file == nil) {
396
+ reject(@"store_failed", error.localizedDescription ?: @"Store unavailable.", error);
397
+ return;
398
+ }
399
+
400
+ NSData *sealed = SealSession([sessionJson dataUsingEncoding:NSUTF8StringEncoding], key);
401
+ if (sealed == nil) {
402
+ reject(@"store_failed", @"Could not encrypt the capture.", nil);
403
+ return;
404
+ }
405
+
406
+ if (![sealed writeToURL:file options:NSDataWritingAtomic error:&error]) {
407
+ reject(@"store_failed", error.localizedDescription, error);
408
+ return;
409
+ }
410
+ resolve(nil);
411
+ }
412
+
413
+ - (void)loadPendingSessions:(RCTPromiseResolveBlock)resolve
414
+ reject:(RCTPromiseRejectBlock)reject {
415
+ NSError *error = nil;
416
+ NSURL *directory = [self storeDirectory:&error];
417
+ NSData *key = [self storeKey:&error];
418
+ if (directory == nil || key == nil) {
419
+ resolve(@[]);
420
+ return;
421
+ }
422
+
423
+ NSArray<NSURL *> *files =
424
+ [[NSFileManager defaultManager] contentsOfDirectoryAtURL:directory
425
+ includingPropertiesForKeys:nil
426
+ options:0
427
+ error:&error];
428
+ NSMutableArray<NSString *> *sessions = [NSMutableArray array];
429
+ for (NSURL *file in files) {
430
+ if (![file.pathExtension isEqualToString:kSessionFileExtension]) continue;
431
+
432
+ NSData *sealed = [NSData dataWithContentsOfURL:file];
433
+ if (sealed == nil) continue;
434
+
435
+ NSData *plaintext = OpenSession(sealed, key);
436
+ // A file that will not open is corrupt or was written under a key that
437
+ // no longer exists. Skip it rather than failing the whole drain — the
438
+ // sessions that can still sync are evidence too.
439
+ if (plaintext == nil) continue;
440
+
441
+ NSString *json = [[NSString alloc] initWithData:plaintext
442
+ encoding:NSUTF8StringEncoding];
443
+ if (json != nil) [sessions addObject:json];
444
+ }
445
+ resolve(sessions);
446
+ }
447
+
448
+ - (void)removeSession:(NSString *)clientSessionId
449
+ resolve:(RCTPromiseResolveBlock)resolve
450
+ reject:(RCTPromiseRejectBlock)reject {
451
+ NSError *error = nil;
452
+ NSURL *file = [self fileForSession:clientSessionId error:&error];
453
+ if (file != nil) {
454
+ [[NSFileManager defaultManager] removeItemAtURL:file error:NULL];
455
+ }
456
+ resolve(nil);
457
+ }
458
+
459
+ - (void)removeAllSessions:(RCTPromiseResolveBlock)resolve
460
+ reject:(RCTPromiseRejectBlock)reject {
461
+ NSError *error = nil;
462
+ NSURL *directory = [self storeDirectory:&error];
463
+ if (directory == nil) {
464
+ resolve(nil);
465
+ return;
466
+ }
467
+
468
+ NSArray<NSURL *> *files =
469
+ [[NSFileManager defaultManager] contentsOfDirectoryAtURL:directory
470
+ includingPropertiesForKeys:nil
471
+ options:0
472
+ error:NULL];
473
+ for (NSURL *file in files) {
474
+ if ([file.pathExtension isEqualToString:kSessionFileExtension]) {
475
+ [[NSFileManager defaultManager] removeItemAtURL:file error:NULL];
476
+ }
477
+ }
478
+ resolve(nil);
479
+ }
480
+
481
+ @end
@@ -0,0 +1,27 @@
1
+ import CryptoKit
2
+ import Foundation
3
+
4
+ /**
5
+ * AES-256-GCM sealing for the encrypted pending-capture store.
6
+ *
7
+ * CommonCrypto's public headers no longer declare a GCM API on current
8
+ * SDKs (no `CCCryptorGCMOneshotEncrypt`/`Decrypt`, no `kCCModeGCM`), and
9
+ * CryptoKit has no Objective-C interface, so this bridges it for
10
+ * DpdpGuardOffline.mm. `AES.GCM.SealedBox.combined` is `nonce(12) ||
11
+ * ciphertext || tag(16)`, matching the on-disk layout the Obj-C++ side
12
+ * already expects.
13
+ */
14
+ @objc(DpdpGuardOfflineCrypto)
15
+ public final class DpdpGuardOfflineCrypto: NSObject {
16
+ @objc public static func seal(_ plaintext: Data, key: Data) -> Data? {
17
+ guard let sealedBox = try? AES.GCM.seal(plaintext, using: SymmetricKey(data: key)) else {
18
+ return nil
19
+ }
20
+ return sealedBox.combined
21
+ }
22
+
23
+ @objc public static func open(_ sealed: Data, key: Data) -> Data? {
24
+ guard let sealedBox = try? AES.GCM.SealedBox(combined: sealed) else { return nil }
25
+ return try? AES.GCM.open(sealedBox, using: SymmetricKey(data: key))
26
+ }
27
+ }
package/package.json CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "name": "@dpdpguard/react-native",
3
- "version": "1.2.0",
4
- "description": "DPDP Guard React Native SDK - typed HTTP client over the DPDP Guard /api/v1 surface, usable via plain fetch (no native module)",
3
+ "version": "2.0.0",
4
+ "description": "DPDP Guard React Native SDK - typed HTTP client over the DPDP Guard /api/v1 surface, plus secure-element-backed offline consent capture",
5
5
  "main": "src/index",
6
6
  "files": [
7
7
  "src",
8
- "!src/**/*.test.ts"
8
+ "android",
9
+ "ios",
10
+ "DpdpGuardReactNative.podspec",
11
+ "!src/**/*.test.ts",
12
+ "!android/build",
13
+ "!android/.gradle"
9
14
  ],
10
15
  "scripts": {
11
16
  "codegen": "node scripts/codegen.mjs",
@@ -27,10 +32,24 @@
27
32
  "@types/jest": "^29.5.14",
28
33
  "@types/node": "^22.20.1",
29
34
  "jest": "^29.7.0",
35
+ "react": "^19.2.8",
36
+ "react-native": "^0.85.3",
30
37
  "ts-jest": "^29.4.11",
31
38
  "typescript": "~5.8"
32
39
  },
33
40
  "dependencies": {
34
41
  "@dpdpguard/contract": "^1.6.0"
42
+ },
43
+ "peerDependencies": {
44
+ "react": ">=19.0.0",
45
+ "react-native": ">=0.85.0"
46
+ },
47
+ "codegenConfig": {
48
+ "name": "DpdpGuardOfflineSpec",
49
+ "type": "modules",
50
+ "jsSrcsDir": "src",
51
+ "android": {
52
+ "javaPackageName": "ai.dpdpguard.offline"
53
+ }
35
54
  }
36
55
  }
@@ -0,0 +1,64 @@
1
+ import { TurboModuleRegistry } from "react-native";
2
+ import type { TurboModule } from "react-native";
3
+
4
+ /**
5
+ * The native surface offline capture needs, and nothing more.
6
+ *
7
+ * Scope is deliberately narrow: **the native side does key storage,
8
+ * signing, and encrypted persistence only.** The `dpdpcca/2`
9
+ * canonicalization stays in JavaScript
10
+ * (`src/offline/captureArtifact.ts`), where one implementation is tested
11
+ * against the contract's golden vectors, rather than being written twice
12
+ * in Swift and Kotlin where the two could silently drift — and drift there
13
+ * means a legitimate consent quarantining as `signature_mismatch` on sync.
14
+ *
15
+ * This mirrors `docs/specs/offline-consent-capture.md` §4.1's "do not build
16
+ * a third consent engine": the engine is the canonical string plus the
17
+ * server's verification, and neither is duplicated here.
18
+ */
19
+ export interface Spec extends TurboModule {
20
+ /**
21
+ * Creates the device's ECDSA P-256 signing key if it does not exist and
22
+ * returns its base64 SPKI DER public key — the value enrolled on the
23
+ * `agentDevices` row. The private key is generated inside the platform's
24
+ * secure element and never leaves the device.
25
+ */
26
+ enrollDevice(): Promise<string>;
27
+
28
+ /** The enrolled public key, or `null` if this device has never enrolled. */
29
+ getPublicKey(): Promise<string | null>;
30
+
31
+ /**
32
+ * `true` when the private key lives in hardware (Secure Enclave /
33
+ * StrongBox or TEE), `false` when the platform forced a software
34
+ * fallback (Simulator, emulator, older devices). Surfaced so a host app
35
+ * can decide whether to allow offline capture on the device at all;
36
+ * the server cannot tell the difference from the signature alone.
37
+ */
38
+ isHardwareBacked(): Promise<boolean>;
39
+
40
+ /**
41
+ * Signs the UTF-8 bytes of `canonical` with ECDSA P-256 / SHA-256 and
42
+ * returns the raw IEEE-P1363 `r||s` pair, base64-encoded — the exact
43
+ * shape `POST /api/v1/offline/captures` verifies.
44
+ */
45
+ sign(canonical: string): Promise<string>;
46
+
47
+ /**
48
+ * Persists one pending session, encrypted at rest, keyed by
49
+ * `clientSessionId`. Overwrites an existing entry with the same id, so
50
+ * a retried capture is idempotent locally as well as at sync.
51
+ */
52
+ saveSession(clientSessionId: string, sessionJson: string): Promise<void>;
53
+
54
+ /** Every pending session's JSON, decrypted. Corrupt entries are skipped. */
55
+ loadPendingSessions(): Promise<string[]>;
56
+
57
+ /** Evicts one session — called only once the server has acknowledged it. */
58
+ removeSession(clientSessionId: string): Promise<void>;
59
+
60
+ /** Evicts every pending session. */
61
+ removeAllSessions(): Promise<void>;
62
+ }
63
+
64
+ export default TurboModuleRegistry.getEnforcing<Spec>("DpdpGuardOffline");
@@ -0,0 +1,63 @@
1
+ /**
2
+ * ADR-002 D5 audit-hash canonicalization, ported from
3
+ * `@dpdpguard/contract`'s `conformance/audit-hash-spec.md` and pinned by the
4
+ * golden vectors in `conformance/audit-hash-vectors.json`.
5
+ *
6
+ * **This module deliberately stops at the canonical string.** The full
7
+ * `auditHash` is `HMAC-SHA256(canonical, DPDP_AUDIT_HASH_HMAC_SECRET)`, and
8
+ * that secret is server-side-only — the spec's "Key management" section is
9
+ * explicit that it never leaves DPDP Guard's infrastructure, and anything
10
+ * shipped in a React Native bundle is extractable from the app binary. So
11
+ * unlike `dpdpguard-ios-sdk` and `dpdpguard-android-sdk`, which both expose
12
+ * `computeAuditHash(input, secret)` because a server-side caller may
13
+ * legitimately hold the key, this package exposes only the unkeyed half.
14
+ *
15
+ * What that half is good for: producing the exact bytes your own backend
16
+ * will sign, and re-deriving the canonical form of a record so you can
17
+ * check a hash your backend computed refers to the event you think it does.
18
+ */
19
+
20
+ /** ADR-004 D5 auth provenance for a consent event. */
21
+ export type ConsentSource = "brokered" | "direct" | "anonymous" | "linked";
22
+
23
+ /**
24
+ * The exact fields the canonicalization is computed over. Every field is
25
+ * always present in the canonical string — an absent {@link source}
26
+ * serializes as the literal `"null"`, never an omitted position — so this is
27
+ * a *versioned*, safety-critical algorithm. Changing field order or the
28
+ * `"null"` sentinel is a major bump requiring fresh DPO sign-off.
29
+ */
30
+ export interface AuditHashInput {
31
+ organizationId: string;
32
+ noticeId: string;
33
+ noticeVersion: number;
34
+ purpose: string;
35
+ dataTypes: string[];
36
+ /** Unix epoch milliseconds. Client-authoritative per ADR-003 D3. */
37
+ givenAt: number;
38
+ source?: ConsentSource | null;
39
+ }
40
+
41
+ /**
42
+ * The `|`-joined canonical string for `input`, per the spec's "Canonical
43
+ * form":
44
+ *
45
+ * ```
46
+ * organizationId|noticeId|noticeVersion|purpose|dataTypes(sorted,joined by ",")|givenAt|source("null" if absent)
47
+ * ```
48
+ *
49
+ * `dataTypes` is sorted lexicographically, so call order never changes the
50
+ * result. The input array is not mutated.
51
+ */
52
+ export function canonicalAuditString(input: AuditHashInput): string {
53
+ const sortedDataTypes = [...input.dataTypes].sort().join(",");
54
+ return [
55
+ input.organizationId,
56
+ input.noticeId,
57
+ String(input.noticeVersion),
58
+ input.purpose,
59
+ sortedDataTypes,
60
+ String(input.givenAt),
61
+ input.source ?? "null",
62
+ ].join("|");
63
+ }