@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,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.
|
|
4
|
-
"description": "DPDP Guard React Native SDK - typed HTTP client over the DPDP Guard /api/v1 surface,
|
|
3
|
+
"version": "2.1.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
|
-
"
|
|
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
|
-
"@dpdpguard/contract": "^1.
|
|
41
|
+
"@dpdpguard/contract": "^1.9.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
|
}
|
package/src/CONTRACT_VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.9.0
|
|
@@ -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");
|