react-native-wallet-keystore 0.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.
Files changed (39) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +376 -0
  3. package/WalletKeystore.podspec +29 -0
  4. package/android/build.gradle +62 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/com/walletkeystore/Secp256k1.kt +158 -0
  7. package/android/src/main/java/com/walletkeystore/WalletKeystoreCrypto.kt +233 -0
  8. package/android/src/main/java/com/walletkeystore/WalletKeystoreModule.kt +627 -0
  9. package/android/src/main/java/com/walletkeystore/WalletKeystorePackage.kt +31 -0
  10. package/ios/WalletKeystore.h +5 -0
  11. package/ios/WalletKeystore.mm +895 -0
  12. package/lib/module/NativeWalletKeystore.js +15 -0
  13. package/lib/module/NativeWalletKeystore.js.map +1 -0
  14. package/lib/module/errors.js +57 -0
  15. package/lib/module/errors.js.map +1 -0
  16. package/lib/module/index.js +9 -0
  17. package/lib/module/index.js.map +1 -0
  18. package/lib/module/keystore.js +220 -0
  19. package/lib/module/keystore.js.map +1 -0
  20. package/lib/module/package.json +1 -0
  21. package/lib/module/viem.js +65 -0
  22. package/lib/module/viem.js.map +1 -0
  23. package/lib/typescript/package.json +1 -0
  24. package/lib/typescript/src/NativeWalletKeystore.d.ts +26 -0
  25. package/lib/typescript/src/NativeWalletKeystore.d.ts.map +1 -0
  26. package/lib/typescript/src/errors.d.ts +29 -0
  27. package/lib/typescript/src/errors.d.ts.map +1 -0
  28. package/lib/typescript/src/index.d.ts +3 -0
  29. package/lib/typescript/src/index.d.ts.map +1 -0
  30. package/lib/typescript/src/keystore.d.ts +100 -0
  31. package/lib/typescript/src/keystore.d.ts.map +1 -0
  32. package/lib/typescript/src/viem.d.ts +24 -0
  33. package/lib/typescript/src/viem.d.ts.map +1 -0
  34. package/package.json +210 -0
  35. package/src/NativeWalletKeystore.ts +43 -0
  36. package/src/errors.ts +94 -0
  37. package/src/index.tsx +6 -0
  38. package/src/keystore.ts +317 -0
  39. package/src/viem.ts +99 -0
@@ -0,0 +1,895 @@
1
+ #import "WalletKeystore.h"
2
+
3
+ #import <LocalAuthentication/LocalAuthentication.h>
4
+ #import <Security/Security.h>
5
+
6
+ #import <secp256k1.h>
7
+ #import <secp256k1_recovery.h>
8
+
9
+ static NSString *const WKPolicyBiometricOnly = @"biometricOnly";
10
+ static NSString *const WKInvalidationOnEnrollmentChange = @"onEnrollmentChange";
11
+
12
+ static NSString *const WKCodeNotAvailable = @"NOT_AVAILABLE";
13
+ static NSString *const WKCodeNotEnrolled = @"NOT_ENROLLED";
14
+ static NSString *const WKCodeUserCanceled = @"USER_CANCELED";
15
+ static NSString *const WKCodeUserFallback = @"USER_FALLBACK";
16
+ static NSString *const WKCodeLockout = @"LOCKOUT";
17
+ static NSString *const WKCodeSystemCancel = @"SYSTEM_CANCEL";
18
+ static NSString *const WKCodeKeyNotFound = @"KEY_NOT_FOUND";
19
+ static NSString *const WKCodeKeyAlreadyExists = @"KEY_ALREADY_EXISTS";
20
+ static NSString *const WKCodeKeyInvalidated = @"KEY_INVALIDATED";
21
+ static NSString *const WKCodeStorageError = @"STORAGE_ERROR";
22
+ static NSString *const WKCodeUnknown = @"UNKNOWN";
23
+
24
+ static NSString *const WKCodeInvalidKey = @"INVALID_KEY";
25
+
26
+ static NSString *const WKKeychainService = @"com.walletkeystore.secret";
27
+ static NSString *const WKPublicKeyService = @"com.walletkeystore.publickey";
28
+ static NSString *const WKKeyTagPrefix = @"com.walletkeystore.wrap.";
29
+
30
+ /**
31
+ * Zeroes a buffer through a volatile pointer. A plain memset over memory that is
32
+ * never read again is dead-store-eliminated at -O3, leaving the key in place.
33
+ */
34
+ static void WKSecureZero(void *buffer, size_t length)
35
+ {
36
+ if (buffer == NULL || length == 0) {
37
+ return;
38
+ }
39
+ volatile unsigned char *p = (volatile unsigned char *)buffer;
40
+ while (length--) {
41
+ *p++ = 0;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Shared libsecp256k1 context — expensive to build, safe to share once
47
+ * randomized. Randomizing blinds against side-channel key recovery.
48
+ */
49
+ static secp256k1_context *WKSecpContext(void)
50
+ {
51
+ static secp256k1_context *context = NULL;
52
+ static dispatch_once_t onceToken;
53
+ dispatch_once(&onceToken, ^{
54
+ context = secp256k1_context_create(SECP256K1_CONTEXT_SIGN | SECP256K1_CONTEXT_VERIFY);
55
+ uint8_t seed[32];
56
+ if (SecRandomCopyBytes(kSecRandomDefault, sizeof(seed), seed) == errSecSuccess) {
57
+ // Hardening only; the context still works if this fails.
58
+ (void)secp256k1_context_randomize(context, seed);
59
+ }
60
+ WKSecureZero(seed, sizeof(seed));
61
+ });
62
+ return context;
63
+ }
64
+
65
+ @implementation WalletKeystore
66
+
67
+ #pragma mark - Error mapping
68
+
69
+ /**
70
+ * Maps an LAError onto the cross-platform code set. No LOCKOUT_PERMANENT case:
71
+ * iOS resolves permanent lockout inside the prompt by demanding the passcode,
72
+ * so it never reaches the app.
73
+ */
74
+ static NSString *WKCodeFromLAError(NSError *_Nullable error)
75
+ {
76
+ if (error == nil) {
77
+ return WKCodeUnknown;
78
+ }
79
+
80
+ switch (error.code) {
81
+ case LAErrorBiometryNotAvailable:
82
+ return WKCodeNotAvailable;
83
+
84
+ // No passcode set means device-owner auth cannot succeed at all, which is
85
+ // an enrollment problem from the caller's point of view, not a fault.
86
+ case LAErrorBiometryNotEnrolled:
87
+ case LAErrorPasscodeNotSet:
88
+ return WKCodeNotEnrolled;
89
+
90
+ // appCancel is the app being backgrounded mid-prompt. Grouped with an
91
+ // explicit tap on Cancel because both mean "no answer, safe to re-prompt".
92
+ case LAErrorUserCancel:
93
+ case LAErrorAppCancel:
94
+ return WKCodeUserCanceled;
95
+
96
+ case LAErrorUserFallback:
97
+ return WKCodeUserFallback;
98
+
99
+ case LAErrorBiometryLockout:
100
+ return WKCodeLockout;
101
+
102
+ // systemCancel is the OS tearing down the prompt; notInteractive means no
103
+ // UI could be presented. Neither is user intent, so neither is a cancel.
104
+ case LAErrorSystemCancel:
105
+ case LAErrorNotInteractive:
106
+ return WKCodeSystemCancel;
107
+
108
+ default:
109
+ return WKCodeUnknown;
110
+ }
111
+ }
112
+
113
+ /** Maps a Keychain/SecKey OSStatus onto the cross-platform code set. */
114
+ static NSString *WKCodeFromOSStatus(OSStatus status)
115
+ {
116
+ switch (status) {
117
+ case errSecItemNotFound:
118
+ return WKCodeKeyNotFound;
119
+
120
+ case errSecDuplicateItem:
121
+ return WKCodeKeyAlreadyExists;
122
+
123
+ case errSecUserCanceled:
124
+ return WKCodeUserCanceled;
125
+
126
+ // No enrolled credential can satisfy the access control. Against a
127
+ // .biometryCurrentSet key this is permanent, not a retryable failure.
128
+ case errSecAuthFailed:
129
+ return WKCodeKeyInvalidated;
130
+
131
+ case errSecInteractionNotAllowed:
132
+ return WKCodeSystemCancel;
133
+
134
+ default:
135
+ return WKCodeStorageError;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * A SecKey CFError may carry an LAError or an OSStatus depending on whether
141
+ * auth or the keychain failed. Unwrap both, or a cancel reads as a storage bug.
142
+ */
143
+ static NSString *WKCodeFromSecError(NSError *_Nullable error)
144
+ {
145
+ if (error == nil) {
146
+ return WKCodeUnknown;
147
+ }
148
+
149
+ if ([error.domain isEqualToString:LAErrorDomain]) {
150
+ return WKCodeFromLAError(error);
151
+ }
152
+
153
+ if ([error.domain isEqualToString:NSOSStatusErrorDomain]) {
154
+ return WKCodeFromOSStatus((OSStatus)error.code);
155
+ }
156
+
157
+ // The underlying error is where LocalAuthentication surfaces when the
158
+ // failure happened inside a SecKey operation rather than an LAContext.
159
+ NSError *underlying = error.userInfo[NSUnderlyingErrorKey];
160
+ if (underlying != nil && [underlying.domain isEqualToString:LAErrorDomain]) {
161
+ return WKCodeFromLAError(underlying);
162
+ }
163
+
164
+ return WKCodeUnknown;
165
+ }
166
+
167
+ #pragma mark - Hex
168
+
169
+ static NSData *_Nullable WKDataFromHex(NSString *hex)
170
+ {
171
+ if (hex.length % 2 != 0) {
172
+ return nil;
173
+ }
174
+
175
+ NSMutableData *data = [NSMutableData dataWithCapacity:hex.length / 2];
176
+ for (NSUInteger i = 0; i < hex.length; i += 2) {
177
+ unsigned int byte = 0;
178
+ NSString *pair = [hex substringWithRange:NSMakeRange(i, 2)];
179
+ if (![[NSScanner scannerWithString:pair] scanHexInt:&byte]) {
180
+ return nil;
181
+ }
182
+ uint8_t value = (uint8_t)byte;
183
+ [data appendBytes:&value length:1];
184
+ }
185
+ return data;
186
+ }
187
+
188
+ static NSString *WKHexFromData(NSData *data)
189
+ {
190
+ const uint8_t *bytes = (const uint8_t *)data.bytes;
191
+ NSMutableString *hex = [NSMutableString stringWithCapacity:data.length * 2];
192
+ for (NSUInteger i = 0; i < data.length; i++) {
193
+ [hex appendFormat:@"%02x", bytes[i]];
194
+ }
195
+ return hex;
196
+ }
197
+
198
+ #pragma mark - Key and item helpers
199
+
200
+ /**
201
+ * Picks an ECIES variant the key accepts, preferring the variable-IV form that
202
+ * Secure Enclave documents. Driven by the private key: the public half accepts
203
+ * more, so choosing on it could store data that never decrypts.
204
+ */
205
+ static SecKeyAlgorithm _Nullable WKECIESAlgorithm(SecKeyRef privateKey)
206
+ {
207
+ SecKeyAlgorithm candidates[] = {
208
+ kSecKeyAlgorithmECIESEncryptionCofactorVariableIVX963SHA256AESGCM,
209
+ kSecKeyAlgorithmECIESEncryptionCofactorX963SHA256AESGCM,
210
+ };
211
+
212
+ for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) {
213
+ if (SecKeyIsAlgorithmSupported(privateKey, kSecKeyOperationTypeDecrypt,
214
+ candidates[i])) {
215
+ return candidates[i];
216
+ }
217
+ }
218
+ return NULL;
219
+ }
220
+
221
+ static NSData *WKKeyTag(NSString *keyId)
222
+ {
223
+ return [[WKKeyTagPrefix stringByAppendingString:keyId]
224
+ dataUsingEncoding:NSUTF8StringEncoding];
225
+ }
226
+
227
+ /**
228
+ * Translates the two orthogonal policies into Secure Enclave access control.
229
+ *
230
+ * The invalidation axis is kept separate from the authenticator axis on
231
+ * purpose. .biometryCurrentSet destroys the key on any enrollment change, so
232
+ * letting 'biometricOnly' imply it would mean a user adding a fingerprint
233
+ * silently loses their wallet. It is opt-in via `invalidation` alone.
234
+ */
235
+ static SecAccessControlCreateFlags WKAccessControlFlags(NSString *policy,
236
+ NSString *invalidation)
237
+ {
238
+ BOOL biometricOnly = [policy isEqualToString:WKPolicyBiometricOnly];
239
+ BOOL invalidates = [invalidation isEqualToString:WKInvalidationOnEnrollmentChange];
240
+
241
+ // Required for any Secure Enclave key that will perform private-key
242
+ // operations. Omitting it still creates the key and still allows public-key
243
+ // encryption, so the mistake only surfaces later, as "Operation is not
244
+ // allowed" on the first decrypt.
245
+ SecAccessControlCreateFlags flags = kSecAccessControlPrivateKeyUsage;
246
+
247
+ if (biometricOnly) {
248
+ return flags | (invalidates ? kSecAccessControlBiometryCurrentSet
249
+ : kSecAccessControlBiometryAny);
250
+ }
251
+
252
+ if (invalidates) {
253
+ // Biometrics pinned to the current enrollment, but the passcode remains a
254
+ // route in — otherwise this would be indistinguishable from biometricOnly.
255
+ return flags | kSecAccessControlBiometryCurrentSet | kSecAccessControlOr |
256
+ kSecAccessControlDevicePasscode;
257
+ }
258
+
259
+ return flags | kSecAccessControlUserPresence;
260
+ }
261
+
262
+ static SecKeyRef _Nullable WKCopyPrivateKey(NSString *keyId,
263
+ LAContext *_Nullable context,
264
+ OSStatus *outStatus)
265
+ {
266
+ NSMutableDictionary *query = [@{
267
+ (__bridge id)kSecClass : (__bridge id)kSecClassKey,
268
+ (__bridge id)kSecAttrApplicationTag : WKKeyTag(keyId),
269
+ (__bridge id)kSecAttrKeyType : (__bridge id)kSecAttrKeyTypeECSECPrimeRandom,
270
+ (__bridge id)kSecReturnRef : @YES,
271
+ } mutableCopy];
272
+
273
+ if (context != nil) {
274
+ // Carries the prompt's reason, and is what makes the decryption below
275
+ // raise the system prompt rather than failing with interactionNotAllowed.
276
+ query[(__bridge id)kSecUseAuthenticationContext] = context;
277
+ }
278
+
279
+ CFTypeRef result = NULL;
280
+ OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
281
+ if (outStatus != NULL) {
282
+ *outStatus = status;
283
+ }
284
+
285
+ return status == errSecSuccess ? (SecKeyRef)result : NULL;
286
+ }
287
+
288
+ static NSDictionary *WKCiphertextQuery(NSString *keyId)
289
+ {
290
+ return @{
291
+ (__bridge id)kSecClass : (__bridge id)kSecClassGenericPassword,
292
+ (__bridge id)kSecAttrService : WKKeychainService,
293
+ (__bridge id)kSecAttrAccount : keyId,
294
+ };
295
+ }
296
+
297
+ static NSDictionary *WKPublicKeyQuery(NSString *keyId)
298
+ {
299
+ return @{
300
+ (__bridge id)kSecClass : (__bridge id)kSecClassGenericPassword,
301
+ (__bridge id)kSecAttrService : WKPublicKeyService,
302
+ (__bridge id)kSecAttrAccount : keyId,
303
+ };
304
+ }
305
+
306
+ #pragma mark - v0.1 authentication
307
+
308
+ - (void)getBiometryType:(RCTPromiseResolveBlock)resolve
309
+ reject:(RCTPromiseRejectBlock)reject
310
+ {
311
+ LAContext *context = [LAContext new];
312
+
313
+ // `biometryType` is unset until a policy has been evaluated, so this call is
314
+ // required even though the result is ignored: we want the hardware modality
315
+ // regardless of enrollment, and canEvaluatePolicy populates it either way.
316
+ NSError *error = nil;
317
+ [context canEvaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics
318
+ error:&error];
319
+
320
+ if (@available(iOS 17.0, *)) {
321
+ if (context.biometryType == LABiometryTypeOpticID) {
322
+ resolve(@"opticId");
323
+ return;
324
+ }
325
+ }
326
+
327
+ switch (context.biometryType) {
328
+ case LABiometryTypeFaceID:
329
+ resolve(@"faceId");
330
+ return;
331
+ case LABiometryTypeTouchID:
332
+ resolve(@"touchId");
333
+ return;
334
+ default:
335
+ resolve(@"none");
336
+ return;
337
+ }
338
+ }
339
+
340
+ - (void)authenticate:(NSString *)reason
341
+ policy:(NSString *)policy
342
+ resolve:(RCTPromiseResolveBlock)resolve
343
+ reject:(RCTPromiseRejectBlock)reject
344
+ {
345
+ // evaluatePolicy raises NSInvalidArgumentException on an empty reason rather
346
+ // than failing gracefully, so it is rejected before we get there.
347
+ if (reason.length == 0) {
348
+ reject(WKCodeUnknown, @"A non-empty `reason` is required to authenticate.", nil);
349
+ return;
350
+ }
351
+
352
+ LAPolicy laPolicy = [policy isEqualToString:WKPolicyBiometricOnly]
353
+ ? LAPolicyDeviceOwnerAuthenticationWithBiometrics
354
+ : LAPolicyDeviceOwnerAuthentication;
355
+
356
+ // Fresh context per call: a reused LAContext can satisfy a later evaluation
357
+ // from a cached success, which would mean signing without authenticating.
358
+ LAContext *context = [LAContext new];
359
+ context.touchIDAuthenticationAllowableReuseDuration = 0;
360
+
361
+ NSError *availabilityError = nil;
362
+ if (![context canEvaluatePolicy:laPolicy error:&availabilityError]) {
363
+ // This is the only place NOT_AVAILABLE and NOT_ENROLLED can be told apart.
364
+ NSString *code = availabilityError ? WKCodeFromLAError(availabilityError)
365
+ : WKCodeNotAvailable;
366
+ NSString *message = availabilityError.localizedDescription
367
+ ?: @"Authentication is not available on this device.";
368
+ reject(code, message, availabilityError);
369
+ return;
370
+ }
371
+
372
+ [context evaluatePolicy:laPolicy
373
+ localizedReason:reason
374
+ reply:^(BOOL success, NSError *_Nullable evaluateError) {
375
+ // This block runs on a private LocalAuthentication queue.
376
+ // The promise blocks are thread-safe, so settle directly
377
+ // rather than hopping to the main queue and stalling it
378
+ // behind the prompt.
379
+ if (success) {
380
+ resolve(@YES);
381
+ return;
382
+ }
383
+
384
+ NSString *message = evaluateError.localizedDescription
385
+ ?: @"Authentication failed.";
386
+ reject(WKCodeFromLAError(evaluateError), message, evaluateError);
387
+ }];
388
+ }
389
+
390
+ #pragma mark - v0.2 secret storage
391
+
392
+ - (void)storeSecret:(NSString *)keyId
393
+ secretHex:(NSString *)secretHex
394
+ policy:(NSString *)policy
395
+ invalidation:(NSString *)invalidation
396
+ resolve:(RCTPromiseResolveBlock)resolve
397
+ reject:(RCTPromiseRejectBlock)reject
398
+ {
399
+ [self storeSecretInternal:keyId
400
+ secretHex:secretHex
401
+ policy:policy
402
+ invalidation:invalidation
403
+ onSuccess:^{ resolve(nil); }
404
+ onError:^(NSString *code, NSString *message) {
405
+ reject(code, message, nil);
406
+ }];
407
+ }
408
+
409
+ /**
410
+ * Reused by generateKey and importPrivateKey, which need the outcome rather
411
+ * than a settled promise. Reporting through blocks keeps one copy of the
412
+ * wrapping logic instead of two that can drift.
413
+ */
414
+ - (void)storeSecretInternal:(NSString *)keyId
415
+ secretHex:(NSString *)secretHex
416
+ policy:(NSString *)policy
417
+ invalidation:(NSString *)invalidation
418
+ onSuccess:(void (^)(void))onSuccess
419
+ onError:(void (^)(NSString *code, NSString *message))onError
420
+ {
421
+ NSData *secret = WKDataFromHex(secretHex);
422
+ if (secret == nil || secret.length == 0) {
423
+ onError(WKCodeUnknown, @"`secretHex` must be a non-empty hex string.");
424
+ return;
425
+ }
426
+
427
+ // Overwriting a wallet key has to be deliberate, so an existing id is an
428
+ // error rather than a silent replace.
429
+ OSStatus existing = errSecSuccess;
430
+ SecKeyRef existingKey = WKCopyPrivateKey(keyId, nil, &existing);
431
+ if (existingKey != NULL) {
432
+ CFRelease(existingKey);
433
+ onError(WKCodeKeyAlreadyExists, @"A secret is already stored under this keyId.");
434
+ return;
435
+ }
436
+
437
+ CFErrorRef acError = NULL;
438
+ SecAccessControlRef access = SecAccessControlCreateWithFlags(
439
+ kCFAllocatorDefault,
440
+ kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
441
+ WKAccessControlFlags(policy, invalidation),
442
+ &acError);
443
+
444
+ if (access == NULL) {
445
+ NSError *error = CFBridgingRelease(acError);
446
+ onError(WKCodeFromSecError(error),
447
+ error.localizedDescription ?: @"Could not build access control.");
448
+ return;
449
+ }
450
+
451
+ NSDictionary *attributes = @{
452
+ (__bridge id)kSecAttrKeyType : (__bridge id)kSecAttrKeyTypeECSECPrimeRandom,
453
+ (__bridge id)kSecAttrKeySizeInBits : @256,
454
+ (__bridge id)kSecAttrTokenID : (__bridge id)kSecAttrTokenIDSecureEnclave,
455
+ (__bridge id)kSecPrivateKeyAttrs : @{
456
+ (__bridge id)kSecAttrIsPermanent : @YES,
457
+ (__bridge id)kSecAttrApplicationTag : WKKeyTag(keyId),
458
+ (__bridge id)kSecAttrAccessControl : (__bridge id)access,
459
+ },
460
+ };
461
+
462
+ CFErrorRef keyError = NULL;
463
+ SecKeyRef privateKey =
464
+ SecKeyCreateRandomKey((__bridge CFDictionaryRef)attributes, &keyError);
465
+ CFRelease(access);
466
+
467
+ if (privateKey == NULL) {
468
+ NSError *error = CFBridgingRelease(keyError);
469
+ onError(WKCodeFromSecError(error),
470
+ error.localizedDescription ?: @"Could not create the wrapping key.");
471
+ return;
472
+ }
473
+
474
+ SecKeyAlgorithm algorithm = WKECIESAlgorithm(privateKey);
475
+ SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey);
476
+ CFRelease(privateKey);
477
+
478
+ if (publicKey == NULL || algorithm == NULL) {
479
+ if (publicKey != NULL) CFRelease(publicKey);
480
+ [self deleteKeyMaterial:keyId];
481
+ onError(WKCodeStorageError,
482
+ algorithm == NULL
483
+ ? @"This device's key does not support any known ECIES variant."
484
+ : @"Could not derive the wrapping public key.");
485
+ return;
486
+ }
487
+
488
+ // Encryption uses only the public half, so it needs no authentication. Only
489
+ // reading the secret back prompts the user.
490
+ CFErrorRef encryptError = NULL;
491
+ NSData *ciphertext = CFBridgingRelease(SecKeyCreateEncryptedData(
492
+ publicKey,
493
+ algorithm,
494
+ (__bridge CFDataRef)secret,
495
+ &encryptError));
496
+ CFRelease(publicKey);
497
+
498
+ if (ciphertext == nil) {
499
+ NSError *error = CFBridgingRelease(encryptError);
500
+ [self deleteKeyMaterial:keyId];
501
+ onError(WKCodeFromSecError(error),
502
+ error.localizedDescription ?: @"Could not encrypt the secret.");
503
+ return;
504
+ }
505
+
506
+ NSMutableDictionary *item = [WKCiphertextQuery(keyId) mutableCopy];
507
+ item[(__bridge id)kSecValueData] = ciphertext;
508
+ // The enclave key is the gate; this item needs no access control of its own,
509
+ // and adding one would prompt twice.
510
+ item[(__bridge id)kSecAttrAccessible] =
511
+ (__bridge id)kSecAttrAccessibleWhenUnlockedThisDeviceOnly;
512
+
513
+ SecItemDelete((__bridge CFDictionaryRef)WKCiphertextQuery(keyId));
514
+ OSStatus addStatus = SecItemAdd((__bridge CFDictionaryRef)item, NULL);
515
+
516
+ if (addStatus != errSecSuccess) {
517
+ // Leaving an enclave key behind with no ciphertext would make the id look
518
+ // taken forever, so roll it back.
519
+ [self deleteKeyMaterial:keyId];
520
+ onError(WKCodeFromOSStatus(addStatus),
521
+ [NSString stringWithFormat:@"Could not store the ciphertext (%d).",
522
+ (int)addStatus]);
523
+ return;
524
+ }
525
+
526
+ onSuccess();
527
+ }
528
+
529
+ - (void)getSecret:(NSString *)keyId
530
+ reason:(NSString *)reason
531
+ resolve:(RCTPromiseResolveBlock)resolve
532
+ reject:(RCTPromiseRejectBlock)reject
533
+ {
534
+ [self getSecretInternal:keyId
535
+ reason:reason
536
+ onSuccess:^(NSData *plaintext) {
537
+ NSString *hex = WKHexFromData(plaintext);
538
+ resolve(hex);
539
+ }
540
+ onError:^(NSString *code, NSString *message) {
541
+ reject(code, message, nil);
542
+ }];
543
+ }
544
+
545
+ /**
546
+ * Hands back raw bytes rather than hex so signDigest never materializes the
547
+ * key as an NSString, which is immutable and cannot be wiped. The buffer is
548
+ * zeroed once the caller's block returns.
549
+ */
550
+ - (void)getSecretInternal:(NSString *)keyId
551
+ reason:(NSString *)reason
552
+ onSuccess:(void (^)(NSData *plaintext))onSuccess
553
+ onError:(void (^)(NSString *code, NSString *message))onError
554
+ {
555
+ NSMutableDictionary *query = [WKCiphertextQuery(keyId) mutableCopy];
556
+ query[(__bridge id)kSecReturnData] = @YES;
557
+
558
+ CFTypeRef stored = NULL;
559
+ OSStatus readStatus =
560
+ SecItemCopyMatching((__bridge CFDictionaryRef)query, &stored);
561
+
562
+ if (readStatus != errSecSuccess) {
563
+ onError(WKCodeFromOSStatus(readStatus),
564
+ readStatus == errSecItemNotFound
565
+ ? @"No secret is stored under this keyId."
566
+ : @"Could not read the stored ciphertext.");
567
+ return;
568
+ }
569
+
570
+ NSData *ciphertext = CFBridgingRelease(stored);
571
+
572
+ LAContext *context = [LAContext new];
573
+ context.touchIDAuthenticationAllowableReuseDuration = 0;
574
+ context.localizedReason = reason;
575
+
576
+ // Decryption runs off the main thread: SecKeyCreateDecryptedData blocks
577
+ // until the user answers the prompt, which would deadlock the UI thread.
578
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
579
+ OSStatus keyStatus = errSecSuccess;
580
+ SecKeyRef privateKey = WKCopyPrivateKey(keyId, context, &keyStatus);
581
+
582
+ if (privateKey == NULL) {
583
+ // Reaching here means the ciphertext was found but its enclave key was
584
+ // not, so the secret is unrecoverable rather than absent. KEY_NOT_FOUND
585
+ // would send the user to store a new key instead of starting recovery.
586
+ onError(keyStatus == errSecItemNotFound ? WKCodeKeyInvalidated
587
+ : WKCodeFromOSStatus(keyStatus),
588
+ keyStatus == errSecItemNotFound
589
+ ? @"The wrapping key no longer exists; this secret cannot be "
590
+ "recovered."
591
+ : @"Could not load the wrapping key.");
592
+ return;
593
+ }
594
+
595
+ SecKeyAlgorithm algorithm = WKECIESAlgorithm(privateKey);
596
+ if (algorithm == NULL) {
597
+ CFRelease(privateKey);
598
+ onError(WKCodeStorageError,
599
+ @"The wrapping key does not support any known ECIES variant.");
600
+ return;
601
+ }
602
+
603
+ CFErrorRef decryptError = NULL;
604
+ NSData *plaintext = CFBridgingRelease(SecKeyCreateDecryptedData(
605
+ privateKey,
606
+ algorithm,
607
+ (__bridge CFDataRef)ciphertext,
608
+ &decryptError));
609
+ CFRelease(privateKey);
610
+
611
+ if (plaintext == nil) {
612
+ NSError *error = CFBridgingRelease(decryptError);
613
+ onError(WKCodeFromSecError(error),
614
+ [NSString stringWithFormat:@"%@ (%@ %ld)",
615
+ error.localizedDescription
616
+ ?: @"Could not decrypt the secret.",
617
+ error.domain ?: @"?", (long)error.code]);
618
+ return;
619
+ }
620
+
621
+ onSuccess(plaintext);
622
+
623
+ // Best effort only, and only over the buffer we own. Anything the callback
624
+ // derived — an NSString, or the JS string it becomes — cannot be wiped.
625
+ WKSecureZero((void *)plaintext.bytes, plaintext.length);
626
+ });
627
+ }
628
+
629
+ - (void)hasSecret:(NSString *)keyId
630
+ resolve:(RCTPromiseResolveBlock)resolve
631
+ reject:(RCTPromiseRejectBlock)reject
632
+ {
633
+ // Deliberately checks only for the ciphertext item, which needs no
634
+ // authentication — probing the enclave key would prompt the user.
635
+ NSMutableDictionary *query = [WKCiphertextQuery(keyId) mutableCopy];
636
+ query[(__bridge id)kSecReturnData] = @NO;
637
+
638
+ OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, NULL);
639
+ resolve(status == errSecSuccess ? @YES : @NO);
640
+ }
641
+
642
+ - (void)deleteSecret:(NSString *)keyId
643
+ resolve:(RCTPromiseResolveBlock)resolve
644
+ reject:(RCTPromiseRejectBlock)reject
645
+ {
646
+ // Idempotent: errSecItemNotFound is success for a teardown path.
647
+ SecItemDelete((__bridge CFDictionaryRef)WKCiphertextQuery(keyId));
648
+ SecItemDelete((__bridge CFDictionaryRef)WKPublicKeyQuery(keyId));
649
+ [self deleteKeyMaterial:keyId];
650
+ resolve(nil);
651
+ }
652
+
653
+ - (void)deleteKeyMaterial:(NSString *)keyId
654
+ {
655
+ NSDictionary *query = @{
656
+ (__bridge id)kSecClass : (__bridge id)kSecClassKey,
657
+ (__bridge id)kSecAttrApplicationTag : WKKeyTag(keyId),
658
+ (__bridge id)kSecAttrKeyType : (__bridge id)kSecAttrKeyTypeECSECPrimeRandom,
659
+ };
660
+ SecItemDelete((__bridge CFDictionaryRef)query);
661
+ }
662
+
663
+
664
+ #pragma mark - v0.3 secp256k1
665
+
666
+ /** Uncompressed SEC1: 0x04 || X || Y, 65 bytes. */
667
+ static NSData *_Nullable WKPublicKeyFromPrivate(NSData *privateKey)
668
+ {
669
+ secp256k1_context *ctx = WKSecpContext();
670
+ secp256k1_pubkey pubkey;
671
+
672
+ if (!secp256k1_ec_pubkey_create(ctx, &pubkey, (const unsigned char *)privateKey.bytes)) {
673
+ return nil;
674
+ }
675
+
676
+ uint8_t serialized[65];
677
+ size_t length = sizeof(serialized);
678
+ if (!secp256k1_ec_pubkey_serialize(ctx, serialized, &length, &pubkey,
679
+ SECP256K1_EC_UNCOMPRESSED)) {
680
+ return nil;
681
+ }
682
+
683
+ return [NSData dataWithBytes:serialized length:length];
684
+ }
685
+
686
+ - (void)persistPublicKey:(NSData *)publicKey forKeyId:(NSString *)keyId
687
+ {
688
+ NSMutableDictionary *item = [WKPublicKeyQuery(keyId) mutableCopy];
689
+ item[(__bridge id)kSecValueData] = publicKey;
690
+ // No access control: a public key is not secret, and prompting to read your
691
+ // own address would be hostile. Device-only so it does not sync.
692
+ item[(__bridge id)kSecAttrAccessible] =
693
+ (__bridge id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly;
694
+
695
+ SecItemDelete((__bridge CFDictionaryRef)WKPublicKeyQuery(keyId));
696
+ SecItemAdd((__bridge CFDictionaryRef)item, NULL);
697
+ }
698
+
699
+ /** Shared tail of generateKey and importPrivateKey. */
700
+ - (void)wrapPrivateKey:(NSData *)privateKey
701
+ keyId:(NSString *)keyId
702
+ policy:(NSString *)policy
703
+ invalidation:(NSString *)invalidation
704
+ resolve:(RCTPromiseResolveBlock)resolve
705
+ reject:(RCTPromiseRejectBlock)reject
706
+ {
707
+ NSData *publicKey = WKPublicKeyFromPrivate(privateKey);
708
+ if (publicKey == nil) {
709
+ reject(WKCodeInvalidKey, @"Could not derive a public key from this private key.", nil);
710
+ return;
711
+ }
712
+
713
+ NSString *secretHex = WKHexFromData(privateKey);
714
+ NSString *publicKeyHex = WKHexFromData(publicKey);
715
+
716
+ [self storeSecretInternal:keyId
717
+ secretHex:secretHex
718
+ policy:policy
719
+ invalidation:invalidation
720
+ onSuccess:^{
721
+ // Recorded only after wrapping succeeded, so a stored
722
+ // public key always implies a retrievable private one.
723
+ [self persistPublicKey:publicKey forKeyId:keyId];
724
+ resolve(publicKeyHex);
725
+ }
726
+ onError:^(NSString *code, NSString *message) {
727
+ reject(code, message, nil);
728
+ }];
729
+ }
730
+
731
+ - (void)generateKey:(NSString *)keyId
732
+ policy:(NSString *)policy
733
+ invalidation:(NSString *)invalidation
734
+ resolve:(RCTPromiseResolveBlock)resolve
735
+ reject:(RCTPromiseRejectBlock)reject
736
+ {
737
+ secp256k1_context *ctx = WKSecpContext();
738
+ uint8_t seckey[32];
739
+
740
+ // Rejection sampling against the curve order rather than reduction, which
741
+ // would bias the distribution toward small keys. Entropy is the platform
742
+ // CSPRNG — never JS, whose PRNG is not cryptographically secure.
743
+ BOOL valid = NO;
744
+ for (int attempt = 0; attempt < 256 && !valid; attempt++) {
745
+ if (SecRandomCopyBytes(kSecRandomDefault, sizeof(seckey), seckey) != errSecSuccess) {
746
+ WKSecureZero(seckey, sizeof(seckey));
747
+ reject(WKCodeStorageError, @"The system random number generator failed.", nil);
748
+ return;
749
+ }
750
+ valid = secp256k1_ec_seckey_verify(ctx, seckey) == 1;
751
+ }
752
+
753
+ if (!valid) {
754
+ WKSecureZero(seckey, sizeof(seckey));
755
+ reject(WKCodeStorageError, @"Could not generate a valid private key.", nil);
756
+ return;
757
+ }
758
+
759
+ NSData *privateKey = [NSData dataWithBytes:seckey length:sizeof(seckey)];
760
+ WKSecureZero(seckey, sizeof(seckey));
761
+
762
+ [self wrapPrivateKey:privateKey
763
+ keyId:keyId
764
+ policy:policy
765
+ invalidation:invalidation
766
+ resolve:resolve
767
+ reject:reject];
768
+ }
769
+
770
+ - (void)importPrivateKey:(NSString *)keyId
771
+ privateKeyHex:(NSString *)privateKeyHex
772
+ policy:(NSString *)policy
773
+ invalidation:(NSString *)invalidation
774
+ resolve:(RCTPromiseResolveBlock)resolve
775
+ reject:(RCTPromiseRejectBlock)reject
776
+ {
777
+ NSData *privateKey = WKDataFromHex(privateKeyHex);
778
+ if (privateKey == nil || privateKey.length != 32) {
779
+ reject(WKCodeInvalidKey, @"A private key must be exactly 32 bytes of hex.", nil);
780
+ return;
781
+ }
782
+
783
+ // Zero and anything at or above the curve order are not merely malformed —
784
+ // they yield signatures that verify against nothing. Rejected, not clamped.
785
+ if (secp256k1_ec_seckey_verify(WKSecpContext(), (const unsigned char *)privateKey.bytes) != 1) {
786
+ reject(WKCodeInvalidKey, @"The private key must be in [1, n-1].", nil);
787
+ return;
788
+ }
789
+
790
+ [self wrapPrivateKey:privateKey
791
+ keyId:keyId
792
+ policy:policy
793
+ invalidation:invalidation
794
+ resolve:resolve
795
+ reject:reject];
796
+ }
797
+
798
+ - (void)getPublicKey:(NSString *)keyId
799
+ resolve:(RCTPromiseResolveBlock)resolve
800
+ reject:(RCTPromiseRejectBlock)reject
801
+ {
802
+ NSMutableDictionary *query = [WKPublicKeyQuery(keyId) mutableCopy];
803
+ query[(__bridge id)kSecReturnData] = @YES;
804
+
805
+ CFTypeRef stored = NULL;
806
+ OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &stored);
807
+
808
+ if (status != errSecSuccess) {
809
+ reject(WKCodeKeyNotFound, @"No key is stored under this keyId.", nil);
810
+ return;
811
+ }
812
+
813
+ resolve(WKHexFromData(CFBridgingRelease(stored)));
814
+ }
815
+
816
+ - (void)signDigest:(NSString *)keyId
817
+ digestHex:(NSString *)digestHex
818
+ reason:(NSString *)reason
819
+ resolve:(RCTPromiseResolveBlock)resolve
820
+ reject:(RCTPromiseRejectBlock)reject
821
+ {
822
+ NSData *digest = WKDataFromHex(digestHex);
823
+ if (digest == nil || digest.length != 32) {
824
+ reject(WKCodeInvalidKey, @"A digest must be exactly 32 bytes of hex.", nil);
825
+ return;
826
+ }
827
+
828
+ [self getSecretInternal:keyId
829
+ reason:reason
830
+ onSuccess:^(NSData *privateKey) {
831
+ secp256k1_context *ctx = WKSecpContext();
832
+ secp256k1_ecdsa_recoverable_signature signature;
833
+
834
+ // The nonce is RFC 6979 deterministic by default. A repeated
835
+ // or predictable nonce reveals the private key algebraically,
836
+ // so this must never be supplied by hand.
837
+ if (!secp256k1_ecdsa_sign_recoverable(
838
+ ctx, &signature,
839
+ (const unsigned char *)digest.bytes,
840
+ (const unsigned char *)privateKey.bytes, NULL, NULL)) {
841
+ reject(WKCodeStorageError, @"Could not sign the digest.", nil);
842
+ return;
843
+ }
844
+
845
+ uint8_t compact[64];
846
+ int recid = 0;
847
+ secp256k1_ecdsa_recoverable_signature_serialize_compact(
848
+ ctx, compact, &recid, &signature);
849
+
850
+ // libsecp256k1 already emits the low-s form required by
851
+ // EIP-2, negating s and flipping recid when needed, so no
852
+ // separate normalization step is correct here.
853
+ uint8_t result[65];
854
+ memcpy(result, compact, 64);
855
+ result[64] = (uint8_t)(recid + 27);
856
+
857
+ NSData *serialized = [NSData dataWithBytes:result length:sizeof(result)];
858
+ WKSecureZero(compact, sizeof(compact));
859
+
860
+ resolve(WKHexFromData(serialized));
861
+ }
862
+ onError:^(NSString *code, NSString *message) {
863
+ reject(code, message, nil);
864
+ }];
865
+ }
866
+
867
+ - (void)exportPrivateKey:(NSString *)keyId
868
+ reason:(NSString *)reason
869
+ resolve:(RCTPromiseResolveBlock)resolve
870
+ reject:(RCTPromiseRejectBlock)reject
871
+ {
872
+ [self getSecretInternal:keyId
873
+ reason:reason
874
+ onSuccess:^(NSData *privateKey) {
875
+ resolve(WKHexFromData(privateKey));
876
+ }
877
+ onError:^(NSString *code, NSString *message) {
878
+ reject(code, message, nil);
879
+ }];
880
+ }
881
+
882
+ #pragma mark - TurboModule
883
+
884
+ - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
885
+ (const facebook::react::ObjCTurboModule::InitParams &)params
886
+ {
887
+ return std::make_shared<facebook::react::NativeWalletKeystoreSpecJSI>(params);
888
+ }
889
+
890
+ + (NSString *)moduleName
891
+ {
892
+ return @"WalletKeystore";
893
+ }
894
+
895
+ @end