react-native-nitro-storage 0.9.0 → 0.10.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 (76) hide show
  1. package/CHANGELOG.md +89 -29
  2. package/README.md +81 -24
  3. package/android/src/main/cpp/AndroidStorageAdapterCpp.cpp +5 -2
  4. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +240 -34
  5. package/cpp/bindings/HybridStorage.cpp +106 -283
  6. package/cpp/bindings/HybridStorage.hpp +16 -9
  7. package/docs/api-reference.md +75 -19
  8. package/docs/benchmarks.md +6 -1
  9. package/docs/keychain-lifecycle-testing.md +36 -0
  10. package/docs/recipes.md +1 -2
  11. package/docs/secure-storage.md +45 -9
  12. package/ios/IOSStorageAdapterCpp.mm +391 -175
  13. package/lib/commonjs/capabilities.js +3 -1
  14. package/lib/commonjs/capabilities.js.map +1 -1
  15. package/lib/commonjs/core/durability.js +110 -43
  16. package/lib/commonjs/core/durability.js.map +1 -1
  17. package/lib/commonjs/index.js +15 -5
  18. package/lib/commonjs/index.js.map +1 -1
  19. package/lib/commonjs/index.web.js +219 -97
  20. package/lib/commonjs/index.web.js.map +1 -1
  21. package/lib/commonjs/internal.js +3 -11
  22. package/lib/commonjs/internal.js.map +1 -1
  23. package/lib/commonjs/shared.js +62 -2
  24. package/lib/commonjs/shared.js.map +1 -1
  25. package/lib/commonjs/storage-core.js +852 -141
  26. package/lib/commonjs/storage-core.js.map +1 -1
  27. package/lib/commonjs/storage-runtime.js +5 -1
  28. package/lib/commonjs/storage-runtime.js.map +1 -1
  29. package/lib/commonjs/testing.js +13 -0
  30. package/lib/commonjs/testing.js.map +1 -1
  31. package/lib/module/capabilities.js +2 -1
  32. package/lib/module/capabilities.js.map +1 -1
  33. package/lib/module/core/durability.js +110 -43
  34. package/lib/module/core/durability.js.map +1 -1
  35. package/lib/module/index.js +12 -8
  36. package/lib/module/index.js.map +1 -1
  37. package/lib/module/index.web.js +215 -99
  38. package/lib/module/index.web.js.map +1 -1
  39. package/lib/module/internal.js +2 -9
  40. package/lib/module/internal.js.map +1 -1
  41. package/lib/module/shared.js +60 -2
  42. package/lib/module/shared.js.map +1 -1
  43. package/lib/module/storage-core.js +853 -142
  44. package/lib/module/storage-core.js.map +1 -1
  45. package/lib/module/storage-runtime.js +4 -1
  46. package/lib/module/storage-runtime.js.map +1 -1
  47. package/lib/module/testing.js +8 -1
  48. package/lib/module/testing.js.map +1 -1
  49. package/lib/typescript/capabilities.d.ts +2 -1
  50. package/lib/typescript/capabilities.d.ts.map +1 -1
  51. package/lib/typescript/core/durability.d.ts +7 -2
  52. package/lib/typescript/core/durability.d.ts.map +1 -1
  53. package/lib/typescript/index.d.ts +2 -2
  54. package/lib/typescript/index.d.ts.map +1 -1
  55. package/lib/typescript/index.web.d.ts +3 -29
  56. package/lib/typescript/index.web.d.ts.map +1 -1
  57. package/lib/typescript/internal.d.ts +0 -2
  58. package/lib/typescript/internal.d.ts.map +1 -1
  59. package/lib/typescript/shared.d.ts +19 -0
  60. package/lib/typescript/shared.d.ts.map +1 -1
  61. package/lib/typescript/storage-core.d.ts +15 -6
  62. package/lib/typescript/storage-core.d.ts.map +1 -1
  63. package/lib/typescript/storage-runtime.d.ts +2 -1
  64. package/lib/typescript/storage-runtime.d.ts.map +1 -1
  65. package/lib/typescript/testing.d.ts +1 -1
  66. package/lib/typescript/testing.d.ts.map +1 -1
  67. package/package.json +1 -1
  68. package/src/capabilities.ts +3 -1
  69. package/src/core/durability.ts +139 -39
  70. package/src/index.ts +20 -10
  71. package/src/index.web.ts +328 -166
  72. package/src/internal.ts +0 -14
  73. package/src/shared.ts +85 -0
  74. package/src/storage-core.ts +1165 -202
  75. package/src/storage-runtime.ts +6 -0
  76. package/src/testing.ts +8 -1
@@ -3,6 +3,10 @@
3
3
  #import <Security/Security.h>
4
4
  #import <LocalAuthentication/LocalAuthentication.h>
5
5
 
6
+ #include <unordered_set>
7
+ #include <utility>
8
+ #include <vector>
9
+
6
10
  namespace NitroStorage {
7
11
 
8
12
  static NSString* const kKeychainService = @"com.nitrostorage.keychain";
@@ -37,24 +41,18 @@ static NSUserDefaults* NitroDiskDefaults() {
37
41
  return defaults ?: [NSUserDefaults standardUserDefaults];
38
42
  }
39
43
 
40
- // --- Legacy disk key registry ---
41
- // Versions before the suite domain stored Disk values in standardUserDefaults.
42
- // The registry records legacy keys observed through the storage API so that
43
- // clearDisk can remove both stores and deleted legacy values cannot reappear.
44
- // Only keys observed through the storage API are ever registered; the host
45
- // app's own standard defaults keys are never enumerated or touched.
46
-
47
44
  static NSSet<NSString*>* registeredLegacyDiskKeys() {
48
45
  NSArray* stored = [NitroDiskDefaults() stringArrayForKey:kLegacyDiskKeysRegistryKey];
49
46
  return stored ? [NSSet setWithArray:stored] : [NSSet set];
50
47
  }
51
48
 
52
49
  static void persistLegacyDiskKeys(NSSet<NSString*>* keys) {
50
+ NSUserDefaults* defaults = NitroDiskDefaults();
53
51
  if (keys.count == 0) {
54
- [NitroDiskDefaults() removeObjectForKey:kLegacyDiskKeysRegistryKey];
52
+ [defaults removeObjectForKey:kLegacyDiskKeysRegistryKey];
55
53
  return;
56
54
  }
57
- [NitroDiskDefaults() setObject:[keys allObjects] forKey:kLegacyDiskKeysRegistryKey];
55
+ [defaults setObject:[keys allObjects] forKey:kLegacyDiskKeysRegistryKey];
58
56
  }
59
57
 
60
58
  static void registerLegacyDiskKey(NSString* key) {
@@ -80,6 +78,123 @@ static void unregisterLegacyDiskKeys(NSArray<NSString*>* keys) {
80
78
  }
81
79
  }
82
80
 
81
+ // --- Legacy disk key migration ---
82
+ // Versions before the suite domain stored Disk values in standardUserDefaults.
83
+ // A conservative, retryable cutover runs at adapter initialization. A valid
84
+ // registry is copied into the suite domain and each source is removed only
85
+ // after a target readback confirms the copy. Malformed registries, fallback
86
+ // domains, and persistence failures remain untouched for a later retry.
87
+
88
+ static NSString* const kLegacyDiskMigrationMarkerKey =
89
+ @"__nitro_storage_legacy_disk_migration_v1__";
90
+
91
+ static void runLegacyDiskMigrationCutover(
92
+ NSUserDefaults* defaults,
93
+ NSUserDefaults* standard
94
+ ) {
95
+ if ([defaults boolForKey:kLegacyDiskMigrationMarkerKey]) {
96
+ return;
97
+ }
98
+
99
+ if (defaults == standard) {
100
+ return;
101
+ }
102
+
103
+ id registryValue = [defaults objectForKey:kLegacyDiskKeysRegistryKey];
104
+ if (registryValue == nil || ![registryValue isKindOfClass:[NSArray class]]) {
105
+ return;
106
+ }
107
+
108
+ NSArray* registry = (NSArray*)registryValue;
109
+ NSMutableArray<NSString*>* keys = [NSMutableArray arrayWithCapacity:registry.count];
110
+ for (id rawKey in registry) {
111
+ if (![rawKey isKindOfClass:[NSString class]]) {
112
+ return;
113
+ }
114
+ NSString* key = (NSString*)rawKey;
115
+ if (key.length == 0 ||
116
+ [key isEqualToString:kLegacyDiskKeysRegistryKey] ||
117
+ [key isEqualToString:kLegacyDiskMigrationMarkerKey]) {
118
+ return;
119
+ }
120
+ [keys addObject:key];
121
+ }
122
+
123
+ for (NSString* key in keys) {
124
+ id legacyValue = [standard objectForKey:key];
125
+ if (legacyValue == nil) {
126
+ continue;
127
+ }
128
+ if (![legacyValue isKindOfClass:[NSString class]]) {
129
+ return;
130
+ }
131
+
132
+ id targetValue = [defaults objectForKey:key];
133
+ if (targetValue == nil) {
134
+ [defaults setObject:legacyValue forKey:key];
135
+ }
136
+ if (![defaults synchronize] ||
137
+ ![[defaults objectForKey:key] isEqual:legacyValue]) {
138
+ return;
139
+ }
140
+
141
+ [standard removeObjectForKey:key];
142
+ if (![standard synchronize] || [standard objectForKey:key] != nil) {
143
+ return;
144
+ }
145
+ }
146
+
147
+ [defaults removeObjectForKey:kLegacyDiskKeysRegistryKey];
148
+ if (![defaults synchronize] ||
149
+ [defaults objectForKey:kLegacyDiskKeysRegistryKey] != nil) {
150
+ return;
151
+ }
152
+
153
+ [defaults setBool:YES forKey:kLegacyDiskMigrationMarkerKey];
154
+ if (![defaults synchronize] ||
155
+ ![defaults boolForKey:kLegacyDiskMigrationMarkerKey]) {
156
+ [defaults removeObjectForKey:kLegacyDiskMigrationMarkerKey];
157
+ [defaults synchronize];
158
+ }
159
+ }
160
+
161
+ #if defined(NITRO_STORAGE_TESTING)
162
+ void runLegacyDiskMigrationCutoverForTesting(NSUserDefaults* defaults) {
163
+ runLegacyDiskMigrationCutover(defaults, [NSUserDefaults standardUserDefaults]);
164
+ }
165
+ #endif
166
+
167
+ static NSString* migrateLegacyDiskValue(NSString* key) {
168
+ NSUserDefaults* defaults = NitroDiskDefaults();
169
+ NSString* result = [defaults stringForKey:key];
170
+ if (result) {
171
+ return result;
172
+ }
173
+
174
+ NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
175
+ NSString* legacyValue = [standard stringForKey:key];
176
+ if (!legacyValue) {
177
+ return nil;
178
+ }
179
+
180
+ registerLegacyDiskKey(key);
181
+ if (defaults == standard) {
182
+ return legacyValue;
183
+ }
184
+
185
+ [defaults setObject:legacyValue forKey:key];
186
+ if (![defaults synchronize] ||
187
+ ![[defaults stringForKey:key] isEqualToString:legacyValue]) {
188
+ return legacyValue;
189
+ }
190
+
191
+ [standard removeObjectForKey:key];
192
+ if ([standard synchronize] && [standard objectForKey:key] == nil) {
193
+ unregisterLegacyDiskKeys(@[key]);
194
+ }
195
+ return [defaults stringForKey:key] ?: legacyValue;
196
+ }
197
+
83
198
  // Prevents the Keychain from showing auth UI. On iOS 14+ kSecUseAuthenticationUIFail is
84
199
  // deprecated; the correct replacement is an LAContext with interactionNotAllowed = YES.
85
200
  static void disableKeychainInteraction(NSMutableDictionary* query) {
@@ -88,6 +203,34 @@ static void disableKeychainInteraction(NSMutableDictionary* query) {
88
203
  query[(__bridge id)kSecUseAuthenticationContext] = ctx;
89
204
  }
90
205
 
206
+ struct BiometricKeychainSnapshot {
207
+ bool present{false};
208
+ std::string value;
209
+ SecAccessControlRef accessControl{nullptr};
210
+
211
+ BiometricKeychainSnapshot() = default;
212
+ BiometricKeychainSnapshot(const BiometricKeychainSnapshot&) = delete;
213
+ BiometricKeychainSnapshot& operator=(const BiometricKeychainSnapshot&) = delete;
214
+ BiometricKeychainSnapshot(BiometricKeychainSnapshot&& other) noexcept
215
+ : present(other.present),
216
+ value(std::move(other.value)),
217
+ accessControl(other.accessControl) {
218
+ other.accessControl = nullptr;
219
+ }
220
+ BiometricKeychainSnapshot& operator=(BiometricKeychainSnapshot&& other) noexcept {
221
+ if (this == &other) return *this;
222
+ if (accessControl) CFRelease(accessControl);
223
+ present = other.present;
224
+ value = std::move(other.value);
225
+ accessControl = other.accessControl;
226
+ other.accessControl = nullptr;
227
+ return *this;
228
+ }
229
+ ~BiometricKeychainSnapshot() {
230
+ if (accessControl) CFRelease(accessControl);
231
+ }
232
+ };
233
+
91
234
  static CFStringRef accessControlAttr(int level) {
92
235
  switch (level) {
93
236
  case 1: return kSecAttrAccessibleAfterFirstUnlock;
@@ -99,7 +242,12 @@ static CFStringRef accessControlAttr(int level) {
99
242
  }
100
243
  }
101
244
 
102
- IOSStorageAdapterCpp::IOSStorageAdapterCpp() {}
245
+ IOSStorageAdapterCpp::IOSStorageAdapterCpp() {
246
+ runLegacyDiskMigrationCutover(
247
+ NitroDiskDefaults(),
248
+ [NSUserDefaults standardUserDefaults]
249
+ );
250
+ }
103
251
  IOSStorageAdapterCpp::~IOSStorageAdapterCpp() {}
104
252
 
105
253
  // --- Disk ---
@@ -110,7 +258,7 @@ void IOSStorageAdapterCpp::setDisk(const std::string& key, const std::string& va
110
258
  NSUserDefaults* defaults = NitroDiskDefaults();
111
259
  [defaults setObject:nsValue forKey:nsKey];
112
260
  NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
113
- if ([standard objectForKey:nsKey] != nil) {
261
+ if (defaults != standard && [standard objectForKey:nsKey] != nil) {
114
262
  [standard removeObjectForKey:nsKey];
115
263
  unregisterLegacyDiskKeys(@[nsKey]);
116
264
  }
@@ -118,29 +266,17 @@ void IOSStorageAdapterCpp::setDisk(const std::string& key, const std::string& va
118
266
 
119
267
  std::optional<std::string> IOSStorageAdapterCpp::getDisk(const std::string& key) {
120
268
  NSString* nsKey = [NSString stringWithUTF8String:key.c_str()];
121
- NSUserDefaults* defaults = NitroDiskDefaults();
122
- NSString* result = [defaults stringForKey:nsKey];
123
-
124
- if (!result) {
125
- NSUserDefaults* legacyDefaults = [NSUserDefaults standardUserDefaults];
126
- NSString* legacyValue = [legacyDefaults stringForKey:nsKey];
127
- if (legacyValue) {
128
- [defaults setObject:legacyValue forKey:nsKey];
129
- [legacyDefaults removeObjectForKey:nsKey];
130
- unregisterLegacyDiskKeys(@[nsKey]);
131
- result = legacyValue;
132
- }
133
- }
134
-
269
+ NSString* result = migrateLegacyDiskValue(nsKey);
135
270
  if (!result) return std::nullopt;
136
271
  return std::string([result UTF8String]);
137
272
  }
138
273
 
139
274
  void IOSStorageAdapterCpp::deleteDisk(const std::string& key) {
140
275
  NSString* nsKey = [NSString stringWithUTF8String:key.c_str()];
141
- [NitroDiskDefaults() removeObjectForKey:nsKey];
276
+ NSUserDefaults* defaults = NitroDiskDefaults();
277
+ [defaults removeObjectForKey:nsKey];
142
278
  NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
143
- if ([standard objectForKey:nsKey] != nil) {
279
+ if (defaults != standard && [standard objectForKey:nsKey] != nil) {
144
280
  [standard removeObjectForKey:nsKey];
145
281
  }
146
282
  unregisterLegacyDiskKeys(@[nsKey]);
@@ -148,8 +284,10 @@ void IOSStorageAdapterCpp::deleteDisk(const std::string& key) {
148
284
 
149
285
  bool IOSStorageAdapterCpp::hasDisk(const std::string& key) {
150
286
  NSString* nsKey = [NSString stringWithUTF8String:key.c_str()];
151
- if ([NitroDiskDefaults() objectForKey:nsKey] != nil) return true;
152
- // Check legacy standardUserDefaults for un-migrated keys
287
+ NSUserDefaults* defaults = NitroDiskDefaults();
288
+ if ([defaults objectForKey:nsKey] != nil) {
289
+ return true;
290
+ }
153
291
  if ([[NSUserDefaults standardUserDefaults] stringForKey:nsKey] != nil) {
154
292
  registerLegacyDiskKey(nsKey);
155
293
  return true;
@@ -160,16 +298,17 @@ bool IOSStorageAdapterCpp::hasDisk(const std::string& key) {
160
298
  std::vector<std::string> IOSStorageAdapterCpp::getAllKeysDisk() {
161
299
  NSUserDefaults* defaults = NitroDiskDefaults();
162
300
  NSDictionary<NSString*, id>* entries = [defaults persistentDomainForName:kDiskSuiteName] ?: @{};
301
+ NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
163
302
  std::unordered_set<std::string> combined;
164
303
  for (NSString* key in entries) {
165
- if (![key isEqualToString:kLegacyDiskKeysRegistryKey]) {
304
+ if (![key isEqualToString:kLegacyDiskKeysRegistryKey] &&
305
+ ![key isEqualToString:kLegacyDiskMigrationMarkerKey]) {
166
306
  combined.insert(std::string([key UTF8String]));
167
307
  }
168
308
  }
169
- // Only keys observed through the storage API are registered legacy keys;
170
- // arbitrary host-app standard defaults keys are never enumerated.
171
309
  for (NSString* key in [registeredLegacyDiskKeys() allObjects]) {
172
- if (entries[key] == nil && ![key isEqualToString:kLegacyDiskKeysRegistryKey]) {
310
+ if ([entries objectForKey:key] == nil &&
311
+ [standard stringForKey:key] != nil) {
173
312
  combined.insert(std::string([key UTF8String]));
174
313
  }
175
314
  }
@@ -208,7 +347,7 @@ void IOSStorageAdapterCpp::setDiskBatch(
208
347
  NSString* nsKey = [NSString stringWithUTF8String:keys[i].c_str()];
209
348
  NSString* nsValue = [NSString stringWithUTF8String:values[i].c_str()];
210
349
  [defaults setObject:nsValue forKey:nsKey];
211
- if ([standard objectForKey:nsKey] != nil) {
350
+ if (defaults != standard && [standard objectForKey:nsKey] != nil) {
212
351
  [legacyKeysToRemove addObject:nsKey];
213
352
  }
214
353
  }
@@ -238,22 +377,25 @@ void IOSStorageAdapterCpp::deleteDiskBatch(const std::vector<std::string>& keys)
238
377
  void IOSStorageAdapterCpp::clearDisk() {
239
378
  NSUserDefaults* defaults = NitroDiskDefaults();
240
379
  NSDictionary<NSString*, id>* entries = [defaults persistentDomainForName:kDiskSuiteName] ?: @{};
241
- // Capture the registry before clearing the suite domain, which removes it.
242
380
  NSMutableSet* legacyKeys = [registeredLegacyDiskKeys() mutableCopy];
243
381
  for (NSString* key in entries) {
382
+ if ([key isEqualToString:kLegacyDiskKeysRegistryKey] ||
383
+ [key isEqualToString:kLegacyDiskMigrationMarkerKey]) {
384
+ continue;
385
+ }
244
386
  [defaults removeObjectForKey:key];
245
387
  }
246
- // Clear the standard-defaults copies of every storage-owned key so legacy
247
- // values deleted here cannot reappear through the read-time migration path.
248
388
  NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
249
- for (NSString* key in entries) {
250
- if ([standard objectForKey:key] != nil) {
389
+ if (defaults != standard) {
390
+ for (NSString* key in entries) {
391
+ if ([key isEqualToString:kLegacyDiskKeysRegistryKey] ||
392
+ [key isEqualToString:kLegacyDiskMigrationMarkerKey]) {
393
+ continue;
394
+ }
251
395
  [standard removeObjectForKey:key];
252
396
  [legacyKeys removeObject:key];
253
397
  }
254
- }
255
- for (NSString* key in legacyKeys) {
256
- if ([standard objectForKey:key] != nil) {
398
+ for (NSString* key in legacyKeys) {
257
399
  [standard removeObjectForKey:key];
258
400
  }
259
401
  }
@@ -274,6 +416,68 @@ static NSMutableDictionary* baseKeychainQuery(NSString* key, NSString* service,
274
416
  return query;
275
417
  }
276
418
 
419
+ static void throwIfDeleteFailed(OSStatus status, const std::string& operation) {
420
+ if (status == errSecSuccess || status == errSecItemNotFound) {
421
+ return;
422
+ }
423
+ if (status == errSecInteractionNotAllowed) {
424
+ throw taggedStorageError(
425
+ "keychain_locked",
426
+ "NitroStorage: Keychain is locked (errSecInteractionNotAllowed). " + operation
427
+ );
428
+ }
429
+ throw keychainStatusError(status, operation);
430
+ }
431
+
432
+ static BiometricKeychainSnapshot captureBiometricValue(
433
+ NSString* nsKey,
434
+ NSString* group
435
+ ) {
436
+ BiometricKeychainSnapshot snapshot;
437
+ NSMutableDictionary* query = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
438
+ query[(__bridge id)kSecReturnAttributes] = @YES;
439
+ query[(__bridge id)kSecReturnData] = @YES;
440
+ query[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne;
441
+ disableKeychainInteraction(query);
442
+
443
+ CFTypeRef result = NULL;
444
+ const OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
445
+ if (status == errSecItemNotFound) {
446
+ return snapshot;
447
+ }
448
+ if (status == errSecInteractionNotAllowed) {
449
+ throw taggedStorageError(
450
+ "keychain_locked",
451
+ "NitroStorage: Keychain is locked (errSecInteractionNotAllowed). "
452
+ "The biometric item is not accessible until the device is unlocked."
453
+ );
454
+ }
455
+ if (status != errSecSuccess || !result) {
456
+ if (result) CFRelease(result);
457
+ throw keychainStatusError(status, "Biometric snapshot");
458
+ }
459
+
460
+ NSDictionary* attributes = (__bridge NSDictionary*)result;
461
+ NSData* data = attributes[(__bridge id)kSecValueData];
462
+ id accessControl = attributes[(__bridge id)kSecAttrAccessControl];
463
+ if (!data || !accessControl) {
464
+ CFRelease(result);
465
+ throw std::runtime_error(
466
+ "NitroStorage: Biometric snapshot did not include value data and access control"
467
+ );
468
+ }
469
+ NSString* stringValue = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
470
+ if (!stringValue) {
471
+ CFRelease(result);
472
+ throw std::runtime_error("NitroStorage: Biometric snapshot value is not UTF-8");
473
+ }
474
+ snapshot.present = true;
475
+ snapshot.value = std::string([stringValue UTF8String]);
476
+ snapshot.accessControl = (SecAccessControlRef)CFRetain((__bridge CFTypeRef)accessControl);
477
+ CFRelease(result);
478
+ return snapshot;
479
+ }
480
+
277
481
  static NSMutableDictionary* allAccountsQuery(NSString* service, NSString* accessGroup) {
278
482
  NSMutableDictionary* query = [@{
279
483
  (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
@@ -358,29 +562,20 @@ static std::optional<std::string> getSecureValue(NSString* nsKey, NSString* grou
358
562
  "The item is not accessible until the device is unlocked."
359
563
  );
360
564
  }
361
- return std::nullopt;
565
+ if (status == errSecItemNotFound) {
566
+ return std::nullopt;
567
+ }
568
+ throw keychainStatusError(status, "Secure get");
362
569
  }
363
570
 
364
571
  static void deleteSecureValue(NSString* nsKey, NSString* group) {
365
572
  NSMutableDictionary* secureQuery = baseKeychainQuery(nsKey, kKeychainService, group);
366
- OSStatus secureStatus = SecItemDelete((__bridge CFDictionaryRef)secureQuery);
367
- if (secureStatus == errSecInteractionNotAllowed) {
368
- throw taggedStorageError(
369
- "keychain_locked",
370
- "NitroStorage: Keychain is locked (errSecInteractionNotAllowed). "
371
- "The item is not accessible until the device is unlocked."
372
- );
373
- }
573
+ const OSStatus secureStatus = SecItemDelete((__bridge CFDictionaryRef)secureQuery);
574
+ throwIfDeleteFailed(secureStatus, "Secure delete");
374
575
 
375
576
  NSMutableDictionary* biometricQuery = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
376
- OSStatus biometricStatus = SecItemDelete((__bridge CFDictionaryRef)biometricQuery);
377
- if (biometricStatus == errSecInteractionNotAllowed) {
378
- throw taggedStorageError(
379
- "keychain_locked",
380
- "NitroStorage: Keychain is locked (errSecInteractionNotAllowed). "
381
- "The item is not accessible until the device is unlocked."
382
- );
383
- }
577
+ const OSStatus biometricStatus = SecItemDelete((__bridge CFDictionaryRef)biometricQuery);
578
+ throwIfDeleteFailed(biometricStatus, "Biometric delete");
384
579
  }
385
580
 
386
581
  // Deletes only the plain (non-biometric) keychain copy. Promotion to biometric
@@ -388,13 +583,42 @@ static void deleteSecureValue(NSString* nsKey, NSString* group) {
388
583
  static void deletePlainSecureValue(NSString* nsKey, NSString* group) {
389
584
  NSMutableDictionary* secureQuery = baseKeychainQuery(nsKey, kKeychainService, group);
390
585
  OSStatus secureStatus = SecItemDelete((__bridge CFDictionaryRef)secureQuery);
391
- if (secureStatus == errSecInteractionNotAllowed) {
392
- throw taggedStorageError(
393
- "keychain_locked",
394
- "NitroStorage: Keychain is locked (errSecInteractionNotAllowed). "
395
- "The item is not accessible until the device is unlocked."
586
+ throwIfDeleteFailed(secureStatus, "Plain secure delete");
587
+ }
588
+
589
+ static void deleteBiometricValue(NSString* nsKey, NSString* group) {
590
+ NSMutableDictionary* biometricQuery = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
591
+ const OSStatus status = SecItemDelete((__bridge CFDictionaryRef)biometricQuery);
592
+ throwIfDeleteFailed(status, "Biometric delete");
593
+ }
594
+
595
+ static void restoreBiometricValue(
596
+ NSString* nsKey,
597
+ NSString* group,
598
+ const BiometricKeychainSnapshot& snapshot
599
+ ) {
600
+ deleteBiometricValue(nsKey, group);
601
+ if (!snapshot.present) {
602
+ return;
603
+ }
604
+ if (!snapshot.accessControl) {
605
+ throw std::runtime_error(
606
+ "NitroStorage: Previous biometric item has no access control"
396
607
  );
397
608
  }
609
+ NSMutableDictionary* query = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
610
+ query[(__bridge id)kSecValueData] = [nsStringFromStdString(snapshot.value) dataUsingEncoding:NSUTF8StringEncoding];
611
+ query[(__bridge id)kSecAttrAccessControl] = (__bridge id)snapshot.accessControl;
612
+ const OSStatus status = SecItemAdd((__bridge CFDictionaryRef)query, NULL);
613
+ if (status != errSecSuccess) {
614
+ if (status == errSecInteractionNotAllowed) {
615
+ throw taggedStorageError(
616
+ "keychain_locked",
617
+ "NitroStorage: Keychain is locked (errSecInteractionNotAllowed) while restoring biometric storage"
618
+ );
619
+ }
620
+ throw keychainStatusError(status, "Biometric restore");
621
+ }
398
622
  }
399
623
 
400
624
  static std::vector<std::string> keychainAccountsForService(NSString* service, NSString* accessGroup) {
@@ -647,137 +871,116 @@ void IOSStorageAdapterCpp::setSecureBiometric(const std::string& key, const std:
647
871
  }
648
872
 
649
873
  void IOSStorageAdapterCpp::setSecureBiometricWithLevel(const std::string& key, const std::string& value, int level) {
874
+ if (level < 0 || level > 2) {
875
+ throw std::runtime_error("NitroStorage: Invalid biometric level");
876
+ }
650
877
  NSString* nsKey = [NSString stringWithUTF8String:key.c_str()];
651
- NSData* data = [[NSString stringWithUTF8String:value.c_str()] dataUsingEncoding:NSUTF8StringEncoding];
878
+ NSData* data = nsDataFromStdString(value);
652
879
  std::string groupStr;
880
+ int accessControlLevel;
653
881
  {
654
882
  std::lock_guard<std::mutex> lock(accessGroupMutex_);
655
883
  groupStr = keychainAccessGroup_;
884
+ accessControlLevel = accessControlLevel_;
656
885
  }
657
886
  NSString* group = groupStr.empty() ? nil : [NSString stringWithUTF8String:groupStr.c_str()];
658
887
 
659
- if (level == 0) {
660
- // Delete any existing biometric keychain entry for this key
661
- NSMutableDictionary* deleteQuery = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
662
- SecItemDelete((__bridge CFDictionaryRef)deleteQuery);
663
- markBiometricKeyRemoved(key);
888
+ const BiometricKeychainSnapshot previousBiometric = captureBiometricValue(nsKey, group);
889
+ const std::optional<std::string> previousPlain = getSecureValue(nsKey, group);
890
+ std::vector<std::string> rollbackErrors;
664
891
 
665
- setSecure(key, value);
666
- return;
667
- }
668
-
669
- // Capture backup before delete — must not prompt for auth
670
- std::optional<std::string> backup = std::nullopt;
671
- {
672
- NSMutableDictionary* backupQuery = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
673
- backupQuery[(__bridge id)kSecReturnData] = @YES;
674
- backupQuery[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne;
675
- disableKeychainInteraction(backupQuery);
676
- CFTypeRef backupResult = NULL;
677
- if (SecItemCopyMatching((__bridge CFDictionaryRef)backupQuery, &backupResult) == errSecSuccess && backupResult) {
678
- NSData* bData = (__bridge_transfer NSData*)backupResult;
679
- NSString* str = [[NSString alloc] initWithData:bData encoding:NSUTF8StringEncoding];
680
- if (str) backup = std::string([str UTF8String]);
681
- } else if (backupResult) {
682
- CFRelease(backupResult);
892
+ try {
893
+ if (level == 0) {
894
+ deleteBiometricValue(nsKey, group);
895
+ markBiometricKeyRemoved(key);
896
+ setSecure(key, value);
897
+ return;
683
898
  }
684
- }
685
899
 
686
- // Delete existing item first (access control can't be updated in place)
687
- NSMutableDictionary* deleteQuery = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
688
- SecItemDelete((__bridge CFDictionaryRef)deleteQuery);
689
-
690
- CFErrorRef error = NULL;
691
- SecAccessControlCreateFlags flags = kSecAccessControlBiometryCurrentSet;
692
- if (level == 1) {
693
- flags = kSecAccessControlUserPresence;
694
- }
695
- SecAccessControlRef access = SecAccessControlCreateWithFlags(
696
- kCFAllocatorDefault,
697
- kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
698
- flags,
699
- &error
700
- );
701
-
702
- if (error || !access) {
703
- if (access) CFRelease(access);
704
- if (error) CFRelease(error);
705
- throw taggedStorageError(
706
- "biometric_unavailable",
707
- "NitroStorage: Failed to create biometric access control"
900
+ // A biometric item's access control cannot be updated in place. Delete
901
+ // the old item only after its value and ACL have been captured.
902
+ deleteBiometricValue(nsKey, group);
903
+
904
+ CFErrorRef error = NULL;
905
+ const SecAccessControlCreateFlags flags =
906
+ level == 1 ? kSecAccessControlUserPresence : kSecAccessControlBiometryCurrentSet;
907
+ SecAccessControlRef access = SecAccessControlCreateWithFlags(
908
+ kCFAllocatorDefault,
909
+ kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
910
+ flags,
911
+ &error
708
912
  );
709
- }
710
-
711
- NSMutableDictionary* attrs = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
712
- attrs[(__bridge id)kSecValueData] = data;
713
- attrs[(__bridge id)kSecAttrAccessControl] = (__bridge_transfer id)access;
714
-
715
- OSStatus addStatus = SecItemAdd((__bridge CFDictionaryRef)attrs, NULL);
716
- if (addStatus != errSecSuccess) {
717
- if (backup.has_value()) {
718
- try {
719
- setSecure(key, *backup);
720
- } catch (const std::exception& restoreEx) {
721
- throw taggedStorageError(
722
- "biometric_unavailable",
723
- std::string("NitroStorage: Biometric set failed with status ") +
724
- std::to_string(addStatus) +
725
- " and previous value restoration also failed: " + restoreEx.what());
726
- }
727
- }
728
- if (addStatus == errSecInteractionNotAllowed) {
913
+ if (error || !access) {
914
+ if (error) CFRelease(error);
915
+ if (access) CFRelease(access);
729
916
  throw taggedStorageError(
730
- "keychain_locked",
731
- "NitroStorage: Keychain is locked (errSecInteractionNotAllowed). "
732
- "The item is not accessible until the device is unlocked."
917
+ "biometric_unavailable",
918
+ "NitroStorage: Failed to create biometric access control"
733
919
  );
734
920
  }
735
- if (addStatus == errSecNotAvailable) {
736
- throw taggedStorageError(
737
- "keychain_locked",
738
- "NitroStorage: Biometric set failed: keychain is unavailable until the device is unlocked (errSecNotAvailable)."
739
- );
921
+
922
+ NSMutableDictionary* attrs = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
923
+ attrs[(__bridge id)kSecValueData] = data;
924
+ attrs[(__bridge id)kSecAttrAccessControl] = (__bridge_transfer id)access;
925
+ const OSStatus addStatus = SecItemAdd((__bridge CFDictionaryRef)attrs, NULL);
926
+ if (addStatus != errSecSuccess) {
927
+ if (addStatus == errSecInteractionNotAllowed) {
928
+ throw taggedStorageError(
929
+ "keychain_locked",
930
+ "NitroStorage: Keychain is locked (errSecInteractionNotAllowed). "
931
+ "The biometric item is not accessible until the device is unlocked."
932
+ );
933
+ }
934
+ throw keychainStatusError(addStatus, "Biometric set");
740
935
  }
741
- throw taggedStorageError(
742
- "biometric_unavailable",
743
- std::string("NitroStorage: Biometric set failed with status ") +
744
- std::to_string(addStatus) +
745
- (backup.has_value() ? " (previous value restored to non-biometric keychain)" : " (no previous value)"));
746
- }
747
936
 
748
- // Promotion contract: the plain secure copy must not survive a biometric
749
- // write, or plain reads would return a stale value (Android removes it too).
750
- // If removing the plain copy fails after the biometric write succeeded,
751
- // roll the biometric write back so the promotion either fully succeeds or
752
- // leaves the previous state intact, then rethrow the original error.
753
- try {
937
+ // A successful promotion has exactly one representation. If this
938
+ // delete fails, compensation restores both prior representations.
754
939
  deletePlainSecureValue(nsKey, group);
755
- } catch (const std::exception& plainDeleteError) {
756
- if (backup.has_value()) {
757
- CFErrorRef restoreError = NULL;
758
- SecAccessControlRef restoreAccess = SecAccessControlCreateWithFlags(
759
- kCFAllocatorDefault,
760
- kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
761
- flags,
762
- &restoreError
763
- );
764
- if (restoreAccess && !restoreError) {
765
- NSMutableDictionary* restoreQuery = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
766
- restoreQuery[(__bridge id)kSecValueData] = [nsStringFromStdString(*backup) dataUsingEncoding:NSUTF8StringEncoding];
767
- restoreQuery[(__bridge id)kSecAttrAccessControl] = (__bridge_transfer id)restoreAccess;
768
- SecItemAdd((__bridge CFDictionaryRef)restoreQuery, NULL);
940
+ markBiometricKeySet(key);
941
+ markSecureKeyRemoved(key);
942
+ } catch (const std::exception& primary) {
943
+ try {
944
+ restoreBiometricValue(nsKey, group, previousBiometric);
945
+ if (previousBiometric.present) {
946
+ markBiometricKeySet(key);
947
+ } else {
948
+ markBiometricKeyRemoved(key);
949
+ }
950
+ } catch (const std::exception& rollbackError) {
951
+ rollbackErrors.push_back(std::string("biometric: ") + rollbackError.what());
952
+ }
953
+
954
+ try {
955
+ if (previousPlain.has_value()) {
956
+ setSecureValue(
957
+ nsKey,
958
+ nsDataFromStdString(*previousPlain),
959
+ group,
960
+ accessControlLevel
961
+ );
962
+ markSecureKeySet(key);
769
963
  } else {
770
- if (restoreError) CFRelease(restoreError);
771
- if (restoreAccess) CFRelease(restoreAccess);
964
+ deletePlainSecureValue(nsKey, group);
965
+ markSecureKeyRemoved(key);
772
966
  }
773
- } else {
774
- NSMutableDictionary* rollbackQuery = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
775
- SecItemDelete((__bridge CFDictionaryRef)rollbackQuery);
967
+ } catch (const std::exception& rollbackError) {
968
+ rollbackErrors.push_back(std::string("plain: ") + rollbackError.what());
969
+ }
970
+ clearSecureKeyCache();
971
+
972
+ if (!rollbackErrors.empty()) {
973
+ const std::string operation =
974
+ level == 0 ? "Secure demotion" : "Biometric promotion";
975
+ throw taggedStorageError(
976
+ "storage_compensation_failed",
977
+ "NitroStorage: " + operation +
978
+ " failed; rollback_error_count=" +
979
+ std::to_string(rollbackErrors.size())
980
+ );
776
981
  }
777
982
  throw;
778
983
  }
779
- markBiometricKeySet(key);
780
- markSecureKeyRemoved(key);
781
984
  }
782
985
 
783
986
  std::optional<std::string> IOSStorageAdapterCpp::getSecureBiometric(const std::string& key) {
@@ -812,7 +1015,10 @@ std::optional<std::string> IOSStorageAdapterCpp::getSecureBiometric(const std::s
812
1015
  "NitroStorage: Biometric authentication failed"
813
1016
  );
814
1017
  }
815
- return std::nullopt;
1018
+ if (status == errSecItemNotFound) {
1019
+ return std::nullopt;
1020
+ }
1021
+ throw keychainStatusError(status, "Biometric get");
816
1022
  }
817
1023
 
818
1024
  void IOSStorageAdapterCpp::deleteSecureBiometric(const std::string& key) {
@@ -824,7 +1030,8 @@ void IOSStorageAdapterCpp::deleteSecureBiometric(const std::string& key) {
824
1030
  }
825
1031
  NSString* group = groupStr.empty() ? nil : [NSString stringWithUTF8String:groupStr.c_str()];
826
1032
  NSMutableDictionary* query = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
827
- SecItemDelete((__bridge CFDictionaryRef)query);
1033
+ const OSStatus status = SecItemDelete((__bridge CFDictionaryRef)query);
1034
+ throwIfDeleteFailed(status, "Biometric delete");
828
1035
  markBiometricKeyRemoved(key);
829
1036
  }
830
1037
 
@@ -838,7 +1045,16 @@ bool IOSStorageAdapterCpp::hasSecureBiometric(const std::string& key) {
838
1045
  NSString* group = groupStr.empty() ? nil : [NSString stringWithUTF8String:groupStr.c_str()];
839
1046
  NSMutableDictionary* query = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
840
1047
  disableKeychainInteraction(query);
841
- return SecItemCopyMatching((__bridge CFDictionaryRef)query, NULL) == errSecSuccess;
1048
+ const OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, NULL);
1049
+ if (status == errSecSuccess) return true;
1050
+ if (status == errSecItemNotFound) return false;
1051
+ if (status == errSecInteractionNotAllowed) {
1052
+ throw taggedStorageError(
1053
+ "keychain_locked",
1054
+ "NitroStorage: Keychain is locked (errSecInteractionNotAllowed) while inspecting biometric storage"
1055
+ );
1056
+ }
1057
+ throw keychainStatusError(status, "Biometric has");
842
1058
  }
843
1059
 
844
1060
  void IOSStorageAdapterCpp::clearSecureBiometric() {