react-native-nitro-storage 0.14.0 → 0.15.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/CHANGELOG.md +16 -0
- package/README.md +203 -4
- package/cpp/bindings/HybridStorage.cpp +21 -0
- package/cpp/bindings/HybridStorage.hpp +2 -0
- package/cpp/core/NativeStorageAdapter.hpp +6 -0
- package/docs/api-reference.md +63 -44
- package/docs/qa/agent-device-replay.md +41 -5
- package/ios/IOSStorageAdapterCpp.hpp +12 -0
- package/ios/IOSStorageAdapterCpp.mm +180 -2
- package/lib/commonjs/index.js +16 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +10 -1
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/shared.js +6 -0
- package/lib/commonjs/shared.js.map +1 -1
- package/lib/commonjs/storage-core.js +79 -0
- package/lib/commonjs/storage-core.js.map +1 -1
- package/lib/commonjs/testing.js +31 -2
- package/lib/commonjs/testing.js.map +1 -1
- package/lib/module/index.js +16 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +10 -1
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/shared.js +5 -0
- package/lib/module/shared.js.map +1 -1
- package/lib/module/storage-core.js +80 -1
- package/lib/module/storage-core.js.map +1 -1
- package/lib/module/testing.js +30 -1
- package/lib/module/testing.js.map +1 -1
- package/lib/typescript/Storage.nitro.d.ts +2 -0
- package/lib/typescript/Storage.nitro.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +4 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/index.web.d.ts +4 -1
- package/lib/typescript/index.web.d.ts.map +1 -1
- package/lib/typescript/shared.d.ts +1 -0
- package/lib/typescript/shared.d.ts.map +1 -1
- package/lib/typescript/storage-core.d.ts +28 -1
- package/lib/typescript/storage-core.d.ts.map +1 -1
- package/lib/typescript/storage-platform.d.ts +2 -0
- package/lib/typescript/storage-platform.d.ts.map +1 -1
- package/lib/typescript/testing.d.ts +9 -1
- package/lib/typescript/testing.d.ts.map +1 -1
- package/nitrogen/generated/shared/c++/HybridStorageSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridStorageSpec.hpp +2 -0
- package/package.json +2 -2
- package/src/Storage.nitro.ts +2 -0
- package/src/index.ts +20 -0
- package/src/index.web.ts +14 -0
- package/src/shared.ts +10 -0
- package/src/storage-core.ts +121 -1
- package/src/storage-platform.ts +2 -0
- package/src/testing.ts +37 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
Breaking changes are always listed first in each release section.
|
|
8
8
|
|
|
9
|
+
## [0.15.0] - 2026-10-07
|
|
10
|
+
|
|
11
|
+
### Breaking changes
|
|
12
|
+
|
|
13
|
+
None.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `migrateSecureAccessControl(level, options?)` sets the default Secure access control level and rewrites existing Secure items so each iOS Keychain item moves to that accessibility class. It reads each stored string and writes it back unchanged, and it never deletes a value. It returns `{ migrated, locked, missing, skipped, failed, enumerationLocked }`: a key whose read or write fails with `keychain_locked` stays untouched and is listed in `locked`; other failures are listed in `failed` with their error code; biometric-protected items, including a key whose biometric check is `keychain_locked`, are listed in `skipped` and are not read. It first runs `flushSecureWrites()`. When that flush or the listing of the Secure keys fails with `keychain_locked`, nothing changes, including the default level, and `enumerationLocked` is `true`; any other listing error is thrown with state unchanged. `options.keys` limits the keys to rewrite. On Android and web it validates the level, records the default, and lists every Secure key in `skipped`. Each rewrite emits a change event with an unchanged value. It runs in JavaScript on the existing native surface, so it works with a 0.14 native binary.
|
|
18
|
+
- `storage.isProtectedDataAvailable()` reports whether iOS protected data is available, from a cached value that is never read on the calling thread. The state starts unknown and unknown reports `false`; the first read on the main thread usually lands within milliseconds and fires the listeners when data is available. A cold background launch inside the lock grace window can read `true` until the app next becomes active, so treat `keychain_locked` and the success of the actual read as the source of truth in background-only code. The value is re-read when the app becomes active or enters the foreground. `storage.onProtectedDataAvailable(listener)` calls `listener` each time protected data becomes available again and returns an unsubscribe function. In an iOS app extension, where `UIApplication` is unavailable, the value is `true`. On Android and web the value is always `true` and the listener never fires. Both need the 0.15.0 native module: rebuild the app. On an older native binary `isProtectedDataAvailable()` returns `true` and `onProtectedDataAvailable` returns a no-op unsubscribe instead of throwing. Both are also on the `react-native-nitro-storage/testing` entry, where availability is `true` until `setMockProtectedDataAvailable(false)` simulates a locked device (`true` fires the listeners; `resetNitroStorageMock()` restores `true` and drops every `onProtectedDataAvailable` listener).
|
|
19
|
+
- New exported types: `SecureAccessControlMigrationOptions` and `SecureAccessControlMigrationResult`.
|
|
20
|
+
|
|
21
|
+
### Documentation
|
|
22
|
+
|
|
23
|
+
- README explains the `setAccessControl` lifecycle (per process, call before the first Secure write, writes only, no effect on Android), when to run `migrateSecureAccessControl`, the protected-data APIs, and how to avoid `keychain_locked` by deferring Secure reads until protected data is available rather than weakening the access class. It compares `AfterFirstUnlock` and `AfterFirstUnlockThisDeviceOnly`, including backup and device-transfer behavior, and notes that raw `storage.getString` has no `fallbackToCacheOnReadError`. It warns that an app extension sharing the Keychain access group can race the read-then-rewrite in `migrateSecureAccessControl`; run it while extensions are idle.
|
|
24
|
+
|
|
9
25
|
## [0.14.0] - 2026-10-03
|
|
10
26
|
|
|
11
27
|
### Breaking changes
|
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ Nitro peer requirement: `react-native-nitro-modules >=0.37.0 <0.38.0`.
|
|
|
68
68
|
|
|
69
69
|
| Tested on | Supported floor |
|
|
70
70
|
| ------------------------------------------ | ----------------------------------- |
|
|
71
|
-
| React Native `0.86.3` / Expo SDK `57.0.
|
|
71
|
+
| React Native `0.86.3` / Expo SDK `57.0.27` | React Native `0.77` / Expo SDK `53` |
|
|
72
72
|
|
|
73
73
|
Nitro Storage supports React Native 0.77 or newer and Expo SDK 53 or newer,
|
|
74
74
|
which is the minimum for Nitro Modules 0.37: its Android package does not
|
|
@@ -82,7 +82,7 @@ package exports are disabled (the default before React Native 0.79).
|
|
|
82
82
|
The package gate uses React Native `0.86.3` and the Strict TypeScript API.
|
|
83
83
|
`check:ci` also compiles the public source against React Native `0.87.0`'s
|
|
84
84
|
Strict TypeScript API; this does not change the runtime baseline. The Expo
|
|
85
|
-
example uses Expo SDK `57.0.
|
|
85
|
+
example uses Expo SDK `57.0.27`, React Native
|
|
86
86
|
`0.86.3`, React `19.2.3`, and Nitro Modules `0.37.1`, which is the React Native
|
|
87
87
|
version supported by that Expo SDK. Do not override Expo's React Native version.
|
|
88
88
|
|
|
@@ -91,7 +91,7 @@ before installing this package, then rebuild the native app so the generated
|
|
|
91
91
|
Nitro bindings and native runtime use the same major-minor version:
|
|
92
92
|
|
|
93
93
|
```sh
|
|
94
|
-
bun add react-native-nitro-modules@0.37.1 react-native-nitro-storage@0.
|
|
94
|
+
bun add react-native-nitro-modules@0.37.1 react-native-nitro-storage@0.15.0
|
|
95
95
|
bunx expo prebuild
|
|
96
96
|
```
|
|
97
97
|
|
|
@@ -578,6 +578,190 @@ storage instance and handle retry failures at the calling boundary. The observer
|
|
|
578
578
|
reports the synchronous backend handoff; it cannot report a later Android
|
|
579
579
|
`apply()` or IndexedDB persistence failure that the backend does not expose.
|
|
580
580
|
|
|
581
|
+
### Access Control Lifecycle
|
|
582
|
+
|
|
583
|
+
`storage.setAccessControl(level)` sets the default `AccessControl` for Secure
|
|
584
|
+
writes. Follow these rules:
|
|
585
|
+
|
|
586
|
+
- It is per process and is not persisted. Call it on every launch, before the
|
|
587
|
+
first Secure write, for example at module scope in your app entry.
|
|
588
|
+
- It affects writes only. Reads never use the level, and an existing Keychain
|
|
589
|
+
item keeps its accessibility class until it is written again. On iOS every
|
|
590
|
+
Secure write, including an update of an existing item, sets
|
|
591
|
+
`kSecAttrAccessible`, so a rewrite moves the item to the current level.
|
|
592
|
+
- An item created with its own `accessControl` option uses that level instead
|
|
593
|
+
of the default.
|
|
594
|
+
- Android has no Keychain accessibility classes. `setAccessControl` only records
|
|
595
|
+
the JavaScript default there and changes no native behavior.
|
|
596
|
+
- Raw `storage.setString(key, value, StorageScope.Secure)` writes use the same
|
|
597
|
+
default level.
|
|
598
|
+
|
|
599
|
+
```ts
|
|
600
|
+
import { AccessControl, storage } from "react-native-nitro-storage";
|
|
601
|
+
|
|
602
|
+
storage.setAccessControl(AccessControl.AfterFirstUnlockThisDeviceOnly);
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
### Migrating Existing Secure Items
|
|
606
|
+
|
|
607
|
+
Items written under `WhenUnlocked` stay unreadable while the device is locked,
|
|
608
|
+
even after you change the default. `migrateSecureAccessControl(level, options?)`
|
|
609
|
+
sets `level` as the default and rewrites each Secure item so the Keychain item
|
|
610
|
+
gets the new class. It reads the stored string and writes the same string back,
|
|
611
|
+
so values are never decoded, changed, or deleted.
|
|
612
|
+
|
|
613
|
+
```ts
|
|
614
|
+
import {
|
|
615
|
+
AccessControl,
|
|
616
|
+
migrateSecureAccessControl,
|
|
617
|
+
} from "react-native-nitro-storage";
|
|
618
|
+
|
|
619
|
+
const result = migrateSecureAccessControl(
|
|
620
|
+
AccessControl.AfterFirstUnlockThisDeviceOnly,
|
|
621
|
+
);
|
|
622
|
+
// { migrated: string[]; locked: string[]; missing: string[];
|
|
623
|
+
// skipped: string[]; failed: { key: string; code?: StorageErrorCode }[];
|
|
624
|
+
// enumerationLocked: boolean }
|
|
625
|
+
|
|
626
|
+
if (result.locked.length > 0) {
|
|
627
|
+
// Retry only the locked keys once protected data is available.
|
|
628
|
+
migrateSecureAccessControl(AccessControl.AfterFirstUnlockThisDeviceOnly, {
|
|
629
|
+
keys: result.locked,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
- Run it while the device is unlocked, for example from a foreground user
|
|
635
|
+
action or after `storage.isProtectedDataAvailable()` returns `true`. Reading
|
|
636
|
+
or rewriting a `WhenUnlocked` item while locked fails with `keychain_locked`.
|
|
637
|
+
- It first runs `flushSecureWrites()` so queued Secure writes land before the
|
|
638
|
+
rewrite, then (without `options.keys`) lists the Secure keys. If the flush or
|
|
639
|
+
the listing fails with `keychain_locked`, it changes nothing, including the
|
|
640
|
+
default level, and returns `enumerationLocked: true` with empty lists (the
|
|
641
|
+
queued writes stay queued). Call it again once
|
|
642
|
+
protected data is available. Any other listing error is thrown and also
|
|
643
|
+
leaves the default level unchanged.
|
|
644
|
+
- A key whose read or write fails with `keychain_locked` is left unchanged and
|
|
645
|
+
listed in `locked`. Any other error leaves the key unchanged and lists it in
|
|
646
|
+
`failed` with its error code. The helper never deletes data.
|
|
647
|
+
- `options.keys` limits the work to specific keys. Without it, every Secure key
|
|
648
|
+
is processed. Biometric-protected items are listed in `skipped` and are never
|
|
649
|
+
read, because reading them can show a biometric prompt. A key whose biometric
|
|
650
|
+
check fails with `keychain_locked` is also listed in `skipped`, not `locked`:
|
|
651
|
+
biometric items are never migrated.
|
|
652
|
+
- The level becomes the default for later writes even when a key fails, so new
|
|
653
|
+
writes already use it.
|
|
654
|
+
- Items that you configured with their own `accessControl` option are rewritten
|
|
655
|
+
with `level` too. Pass `options.keys` to leave them out.
|
|
656
|
+
- On Android and web there is no accessibility class to change. The helper
|
|
657
|
+
validates the level, records the default, reads and writes nothing, and lists
|
|
658
|
+
every Secure key in `skipped`.
|
|
659
|
+
- Each rewrite is a native write, so storage listeners and observers receive a
|
|
660
|
+
change event for every migrated key, even though the value is unchanged.
|
|
661
|
+
- It reads each key and writes it back as separate steps. An app extension that
|
|
662
|
+
shares the Keychain access group and writes the same key between the two steps
|
|
663
|
+
can lose that write. Run the migration while extensions are idle.
|
|
664
|
+
- `migrateSecureAccessControl` is JavaScript only and works with any
|
|
665
|
+
0.14-compatible native module. The protected-data APIs below need the 0.15.0
|
|
666
|
+
native module.
|
|
667
|
+
- Its result has `enumerationLocked: boolean` besides the key lists.
|
|
668
|
+
|
|
669
|
+
### Protected Data Availability
|
|
670
|
+
|
|
671
|
+
On iOS, Keychain items in the `WhenUnlocked` classes (`WhenUnlocked`,
|
|
672
|
+
`WhenUnlockedThisDeviceOnly`, and `WhenPasscodeSetThisDeviceOnly`) can be read
|
|
673
|
+
only while protected data is available. Use these APIs to
|
|
674
|
+
wait instead of catching `keychain_locked`:
|
|
675
|
+
|
|
676
|
+
```ts
|
|
677
|
+
import { storage } from "react-native-nitro-storage";
|
|
678
|
+
|
|
679
|
+
const available: boolean = storage.isProtectedDataAvailable();
|
|
680
|
+
|
|
681
|
+
const unsubscribe = storage.onProtectedDataAvailable(() => {
|
|
682
|
+
// Protected data became available again.
|
|
683
|
+
});
|
|
684
|
+
unsubscribe();
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
- `isProtectedDataAvailable()` returns a cached value and never blocks. The
|
|
688
|
+
state starts unknown, and unknown reports `false`, so a locked device is never
|
|
689
|
+
reported as open. The first value is read from `UIApplication` on the main
|
|
690
|
+
thread: synchronously when the native module is created on the main thread,
|
|
691
|
+
otherwise asynchronously on the main queue, usually within a few
|
|
692
|
+
milliseconds. Until then the first calls can report `false` even when data is
|
|
693
|
+
available. When the read finds protected data available, listeners fire, so
|
|
694
|
+
gate on `onProtectedDataAvailable` instead of treating an early `false` as
|
|
695
|
+
final. iOS keeps the value current from the
|
|
696
|
+
`UIApplicationProtectedDataDidBecomeAvailable` and
|
|
697
|
+
`UIApplicationProtectedDataWillBecomeUnavailable` notifications, and
|
|
698
|
+
re-reads `UIApplication` when the app becomes active or enters the
|
|
699
|
+
foreground, because the available notification is not guaranteed when the
|
|
700
|
+
user unlocks inside the short window before protected data drops.
|
|
701
|
+
- Residual cases. A cold background launch that happens inside the grace
|
|
702
|
+
window after the device locks reads `true` until the app next becomes active,
|
|
703
|
+
because `UIApplication` still reports available during that window. A
|
|
704
|
+
background-only consumer can also keep a `false` cache after an unlock inside
|
|
705
|
+
the grace window until the next foreground. Treat `keychain_locked` and the
|
|
706
|
+
success of the actual read as the source of truth, not the cached value.
|
|
707
|
+
Before `UIApplication` exists (before `UIApplicationMain`), the state stays
|
|
708
|
+
unknown and reports `false` until the app becomes active.
|
|
709
|
+
- `onProtectedDataAvailable(listener)` calls `listener` each time protected data
|
|
710
|
+
changes from unavailable or unknown to available, and returns an unsubscribe
|
|
711
|
+
function. It does not call the listener on subscribe. Subscribe first, then
|
|
712
|
+
check `isProtectedDataAvailable()`, so a change between the two calls is not
|
|
713
|
+
missed.
|
|
714
|
+
- Both functions need the 0.15.0 native module. On an older native binary,
|
|
715
|
+
`isProtectedDataAvailable()` returns `true` and `onProtectedDataAvailable`
|
|
716
|
+
returns a no-op unsubscribe. Rebuild the app to get real values.
|
|
717
|
+
- In an iOS app extension `UIApplication` is not available. The module cannot
|
|
718
|
+
read the state there, so `isProtectedDataAvailable()` returns `true` and
|
|
719
|
+
`keychain_locked` remains the signal.
|
|
720
|
+
- Android and web always return `true`, and the listener never fires.
|
|
721
|
+
|
|
722
|
+
### Handling keychain_locked
|
|
723
|
+
|
|
724
|
+
When a production app sees `keychain_locked` because Secure reads run while the
|
|
725
|
+
device is locked, for example during a background launch, defer those reads
|
|
726
|
+
until protected data is available. This keeps the default `WhenUnlocked`
|
|
727
|
+
protection.
|
|
728
|
+
|
|
729
|
+
```ts
|
|
730
|
+
import { storage } from "react-native-nitro-storage";
|
|
731
|
+
|
|
732
|
+
export function whenProtectedDataAvailable(run: () => void): () => void {
|
|
733
|
+
let done = false;
|
|
734
|
+
const runOnce = () => {
|
|
735
|
+
if (done) return;
|
|
736
|
+
done = true;
|
|
737
|
+
unsubscribe();
|
|
738
|
+
run();
|
|
739
|
+
};
|
|
740
|
+
const unsubscribe = storage.onProtectedDataAvailable(runOnce);
|
|
741
|
+
if (storage.isProtectedDataAvailable()) runOnce();
|
|
742
|
+
return unsubscribe;
|
|
743
|
+
}
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
Moving items to `AfterFirstUnlock` or `AfterFirstUnlockThisDeviceOnly` removes
|
|
747
|
+
the lock failure after the first unlock following a restart, but it weakens
|
|
748
|
+
protection: the items stay readable while the device is locked. Choose the
|
|
749
|
+
`ThisDeviceOnly` variant when items must not leave the device:
|
|
750
|
+
|
|
751
|
+
| Level | Readable while locked after first unlock | Included in backups and device transfer |
|
|
752
|
+
| -------------------------------- | ---------------------------------------- | --------------------------------------- |
|
|
753
|
+
| `WhenUnlocked` | No | Yes |
|
|
754
|
+
| `AfterFirstUnlock` | Yes | Yes |
|
|
755
|
+
| `AfterFirstUnlockThisDeviceOnly` | Yes | No |
|
|
756
|
+
|
|
757
|
+
Items in a `ThisDeviceOnly` class are not restored onto a new device from a
|
|
758
|
+
backup or device transfer, so the user must sign in again there.
|
|
759
|
+
|
|
760
|
+
Raw `storage.getString(key, StorageScope.Secure)` has no
|
|
761
|
+
`fallbackToCacheOnReadError`. It throws `keychain_locked` while the Keychain is
|
|
762
|
+
locked. Only typed items created with `fallbackToCacheOnReadError: true` return
|
|
763
|
+
their last cached value for that error.
|
|
764
|
+
|
|
581
765
|
## Batch Operations
|
|
582
766
|
|
|
583
767
|
`getBatch()` preserves tuple value types, so IDEs infer each result from the
|
|
@@ -759,7 +943,10 @@ and Storybook run without native modules. Item subscribers and hooks re-render
|
|
|
759
943
|
for Memory, Disk, and Secure writes. It does not model platform behaviour:
|
|
760
944
|
there are no keychain locks, biometric prompts, access-control levels,
|
|
761
945
|
coalesced native write timing, or web backends (the web backend functions are
|
|
762
|
-
no-ops). Mock the package with it, or use it directly.
|
|
946
|
+
no-ops). Mock the package with it, or use it directly. To simulate a locked
|
|
947
|
+
device, call `setMockProtectedDataAvailable(false)`; `true` fires the
|
|
948
|
+
`onProtectedDataAvailable` listeners, and `resetNitroStorageMock()` restores
|
|
949
|
+
`true` and drops every `onProtectedDataAvailable` listener.
|
|
763
950
|
|
|
764
951
|
```ts
|
|
765
952
|
import {
|
|
@@ -780,6 +967,18 @@ beforeEach(() => {
|
|
|
780
967
|
const { storage, memoryItem } = createNitroStorageMock();
|
|
781
968
|
```
|
|
782
969
|
|
|
970
|
+
```ts
|
|
971
|
+
import {
|
|
972
|
+
setMockProtectedDataAvailable,
|
|
973
|
+
storage,
|
|
974
|
+
} from "react-native-nitro-storage/testing";
|
|
975
|
+
|
|
976
|
+
const stop = storage.onProtectedDataAvailable(() => loadSession());
|
|
977
|
+
setMockProtectedDataAvailable(false); // storage.isProtectedDataAvailable() === false
|
|
978
|
+
setMockProtectedDataAvailable(true); // listener fires once
|
|
979
|
+
stop();
|
|
980
|
+
```
|
|
981
|
+
|
|
783
982
|
## API
|
|
784
983
|
|
|
785
984
|
The package exposes named `storage`, `createStorageItem`, the scoped item
|
|
@@ -535,6 +535,27 @@ void HybridStorage::clearSecureBiometric() {
|
|
|
535
535
|
notifyListeners(static_cast<int>(Scope::Secure), kClearSentinelKey, std::nullopt);
|
|
536
536
|
}
|
|
537
537
|
|
|
538
|
+
bool HybridStorage::isProtectedDataAvailable() {
|
|
539
|
+
ensureAdapter();
|
|
540
|
+
return runAdapterOperation(
|
|
541
|
+
[&] { return nativeAdapter_->isProtectedDataAvailable(); },
|
|
542
|
+
"Protected data availability");
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
std::function<void()> HybridStorage::onProtectedDataAvailable(const std::function<void()>& listener) {
|
|
546
|
+
ensureAdapter();
|
|
547
|
+
return runAdapterOperation(
|
|
548
|
+
[&] {
|
|
549
|
+
return nativeAdapter_->addProtectedDataAvailableListener([listener]() {
|
|
550
|
+
try {
|
|
551
|
+
listener();
|
|
552
|
+
} catch (...) {
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
},
|
|
556
|
+
"Protected data subscription");
|
|
557
|
+
}
|
|
558
|
+
|
|
538
559
|
// --- Internal ---
|
|
539
560
|
|
|
540
561
|
std::vector<HybridStorage::Listener> HybridStorage::copyListenersForScope(int scope) {
|
|
@@ -56,6 +56,8 @@ public:
|
|
|
56
56
|
void deleteSecureBiometric(const std::string& key) override;
|
|
57
57
|
bool hasSecureBiometric(const std::string& key) override;
|
|
58
58
|
void clearSecureBiometric() override;
|
|
59
|
+
bool isProtectedDataAvailable() override;
|
|
60
|
+
std::function<void()> onProtectedDataAvailable(const std::function<void()>& listener) override;
|
|
59
61
|
|
|
60
62
|
private:
|
|
61
63
|
enum class Scope {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#pragma once
|
|
2
2
|
|
|
3
3
|
#include <cstddef>
|
|
4
|
+
#include <functional>
|
|
4
5
|
#include <optional>
|
|
5
6
|
#include <stdexcept>
|
|
6
7
|
#include <string>
|
|
@@ -58,6 +59,11 @@ public:
|
|
|
58
59
|
virtual void deleteSecureBiometric(const std::string& key) = 0;
|
|
59
60
|
virtual bool hasSecureBiometric(const std::string& key) = 0;
|
|
60
61
|
virtual void clearSecureBiometric() = 0;
|
|
62
|
+
|
|
63
|
+
virtual bool isProtectedDataAvailable() { return true; }
|
|
64
|
+
virtual std::function<void()> addProtectedDataAvailableListener(std::function<void()>) {
|
|
65
|
+
return []() {};
|
|
66
|
+
}
|
|
61
67
|
};
|
|
62
68
|
|
|
63
69
|
} // namespace NitroStorage
|
package/docs/api-reference.md
CHANGED
|
@@ -112,50 +112,52 @@ See [react-hooks.md](react-hooks.md).
|
|
|
112
112
|
|
|
113
113
|
`storage` exposes raw and cross-item utilities:
|
|
114
114
|
|
|
115
|
-
| Method | Purpose
|
|
116
|
-
| ------------------------------------------------ |
|
|
117
|
-
| `clear(scope, options?)` | Clear one scope, optionally preserving selected keys.
|
|
118
|
-
| `clearAll()` | Clear Memory, Disk, and Secure scopes.
|
|
119
|
-
| `clearNamespace(namespace, scope)` | Remove keys under `namespace:`.
|
|
120
|
-
| `clearGroup(group)` | Remove registered items in a group across their scopes.
|
|
121
|
-
| `getGroupItems(group)` | List registered items in a group.
|
|
122
|
-
| `subscribeExpired(scope, listener)` | Receive item events caused by TTL expiry.
|
|
123
|
-
| `findDuplicateKeys()` | Find duplicate registered `(scope, key)` definitions.
|
|
124
|
-
| `getRegisteredKeys()` | List registered `(scope, key)` definitions.
|
|
125
|
-
| `subscribe(scope, listener)` | Subscribe to raw scope-level change events.
|
|
126
|
-
| `subscribeKey(scope, key, listener)` | Subscribe to raw events for one key.
|
|
127
|
-
| `subscribePrefix(scope, prefix, listener)` | Subscribe to raw events for matching key prefixes.
|
|
128
|
-
| `subscribeNamespace(namespace, scope, listener)` | Subscribe to raw events for `namespace:` keys.
|
|
129
|
-
| `setEventObserver(observer, options?)` | Receive all change events for devtools or logging. Secure values are redacted by default.
|
|
130
|
-
| `clearBiometric()` | Clear biometric Secure entries.
|
|
131
|
-
| `has(key, scope)` | Check for a raw key.
|
|
132
|
-
| `getAllKeys(scope)` | List raw keys.
|
|
133
|
-
| `getKeysByPrefix(prefix, scope)` | List raw keys with a prefix.
|
|
134
|
-
| `getByPrefix(prefix, scope)` | Read raw string values by prefix.
|
|
135
|
-
| `getAll(scope)` | Read all raw string values in a scope.
|
|
136
|
-
| `size(scope)` | Return approximate scope entry count.
|
|
137
|
-
| `setAccessControl(accessControl)` | Set the default Secure access control level.
|
|
138
|
-
| `
|
|
139
|
-
| `
|
|
140
|
-
| `
|
|
141
|
-
| `
|
|
142
|
-
| `
|
|
143
|
-
| `
|
|
144
|
-
| `
|
|
145
|
-
| `
|
|
146
|
-
| `
|
|
147
|
-
| `
|
|
148
|
-
| `
|
|
149
|
-
| `
|
|
150
|
-
| `
|
|
151
|
-
| `
|
|
152
|
-
| `
|
|
153
|
-
| `
|
|
154
|
-
| `
|
|
155
|
-
| `
|
|
156
|
-
| `
|
|
157
|
-
| `
|
|
158
|
-
| `
|
|
115
|
+
| Method | Purpose |
|
|
116
|
+
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
117
|
+
| `clear(scope, options?)` | Clear one scope, optionally preserving selected keys. |
|
|
118
|
+
| `clearAll()` | Clear Memory, Disk, and Secure scopes. |
|
|
119
|
+
| `clearNamespace(namespace, scope)` | Remove keys under `namespace:`. |
|
|
120
|
+
| `clearGroup(group)` | Remove registered items in a group across their scopes. |
|
|
121
|
+
| `getGroupItems(group)` | List registered items in a group. |
|
|
122
|
+
| `subscribeExpired(scope, listener)` | Receive item events caused by TTL expiry. |
|
|
123
|
+
| `findDuplicateKeys()` | Find duplicate registered `(scope, key)` definitions. |
|
|
124
|
+
| `getRegisteredKeys()` | List registered `(scope, key)` definitions. |
|
|
125
|
+
| `subscribe(scope, listener)` | Subscribe to raw scope-level change events. |
|
|
126
|
+
| `subscribeKey(scope, key, listener)` | Subscribe to raw events for one key. |
|
|
127
|
+
| `subscribePrefix(scope, prefix, listener)` | Subscribe to raw events for matching key prefixes. |
|
|
128
|
+
| `subscribeNamespace(namespace, scope, listener)` | Subscribe to raw events for `namespace:` keys. |
|
|
129
|
+
| `setEventObserver(observer, options?)` | Receive all change events for devtools or logging. Secure values are redacted by default. |
|
|
130
|
+
| `clearBiometric()` | Clear biometric Secure entries. |
|
|
131
|
+
| `has(key, scope)` | Check for a raw key. |
|
|
132
|
+
| `getAllKeys(scope)` | List raw keys. |
|
|
133
|
+
| `getKeysByPrefix(prefix, scope)` | List raw keys with a prefix. |
|
|
134
|
+
| `getByPrefix(prefix, scope)` | Read raw string values by prefix. |
|
|
135
|
+
| `getAll(scope)` | Read all raw string values in a scope. |
|
|
136
|
+
| `size(scope)` | Return approximate scope entry count. |
|
|
137
|
+
| `setAccessControl(accessControl)` | Set the default Secure access control level. |
|
|
138
|
+
| `isProtectedDataAvailable()` | Read cached iOS protected-data availability; may report `false` briefly until the first read lands. Android, web, and 0.14 native binaries return `true`. |
|
|
139
|
+
| `onProtectedDataAvailable(listener)` | Call `listener` when protected data becomes available (including the first known-available read); returns an unsubscribe function. Android and web never fire. |
|
|
140
|
+
| `setSecureWritesAsync(enabled)` | Toggle Android secure writes between sync and async modes. |
|
|
141
|
+
| `setDiskWritesAsync(enabled)` | Toggle coalesced Disk write behavior. |
|
|
142
|
+
| `flushDiskWrites()` | Flush pending Disk writes. |
|
|
143
|
+
| `flushSecureWrites()` | Drain queued Secure writes into the backend; not an Android `apply()` persistence barrier. |
|
|
144
|
+
| `setScheduledFlushErrorObserver(observer)` | Observe scheduled Disk/Secure flush failures; pass `undefined` to restore uncaught failures. Explicit flush calls still throw. |
|
|
145
|
+
| `setKeychainAccessGroup(group)` | Configure iOS Keychain access group. |
|
|
146
|
+
| `setMetricsObserver(observer)` | Receive operation timing events. |
|
|
147
|
+
| `getMetricsSnapshot()` | Read aggregated metrics. |
|
|
148
|
+
| `getScopedMetricsSnapshot()` | Read metrics grouped by storage scope. |
|
|
149
|
+
| `resetMetrics()` | Clear metrics counters. |
|
|
150
|
+
| `getCacheMetrics()` | Read raw-cache hits, misses, live entries, and estimated bytes. |
|
|
151
|
+
| `getCapabilities()` | Read runtime storage capabilities. Native `backend.disk` is `"sqlite"`. |
|
|
152
|
+
| `getSecurityCapabilities()` | Read secure backend capability metadata. |
|
|
153
|
+
| `getSecureMetadata(key)` | Read secure metadata for one key without returning its value. |
|
|
154
|
+
| `getAllSecureMetadata()` | Read secure metadata for all secure keys without values. |
|
|
155
|
+
| `getString(key, scope)` | Read a raw string. |
|
|
156
|
+
| `setString(key, value, scope)` | Write a raw string. |
|
|
157
|
+
| `deleteString(key, scope)` | Remove a raw key. |
|
|
158
|
+
| `export(scope, options?)` | Snapshot raw strings from one scope. Secure scope requires explicit unsafe opt-in. |
|
|
159
|
+
| `exportSecureUnsafe()` | Snapshot raw Secure strings for short-lived migration workflows. |
|
|
160
|
+
| `import(data, scope)` | Bulk import raw strings. |
|
|
159
161
|
|
|
160
162
|
Raw string APIs bypass item serialization and validation. Prefer `StorageItem<T>` unless you are migrating, exporting/importing, or writing a custom integration.
|
|
161
163
|
|
|
@@ -256,6 +258,12 @@ Migration versions are tracked per scope.
|
|
|
256
258
|
Migration callbacks must be synchronous. An async callback is rejected without
|
|
257
259
|
advancing its version, and the next migration run retries that step.
|
|
258
260
|
|
|
261
|
+
## Secure Access Control Migration
|
|
262
|
+
|
|
263
|
+
| API | Purpose |
|
|
264
|
+
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
265
|
+
| `migrateSecureAccessControl(level, options?)` | Set the default Secure access control level and rewrite each Secure item so its iOS Keychain item moves to that class. Returns `{ migrated, locked, missing, skipped, failed, enumerationLocked }`. It runs `flushSecureWrites()` first. `enumerationLocked: true` means that flush or listing the Secure keys failed with `keychain_locked`: nothing was changed, including the default level, and queued writes stay queued; retry later. Other enumeration errors are thrown with state unchanged. `options.keys` limits the keys. JavaScript only; Android and web record the default and list every key in `skipped`. |
|
|
266
|
+
|
|
259
267
|
## Secure Auth Storage
|
|
260
268
|
|
|
261
269
|
```ts
|
|
@@ -319,6 +327,15 @@ function as typed no-ops for shared code.
|
|
|
319
327
|
|
|
320
328
|
See [web-backends.md](web-backends.md).
|
|
321
329
|
|
|
330
|
+
## Testing Entry
|
|
331
|
+
|
|
332
|
+
`react-native-nitro-storage/testing` exports an in-memory implementation of the public surface.
|
|
333
|
+
|
|
334
|
+
| API | Description |
|
|
335
|
+
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
336
|
+
| `setMockProtectedDataAvailable(available)` | Set the mock protected-data availability (default `true`). `false` simulates a locked device; `true` after `false` fires the `onProtectedDataAvailable` listeners. Throws a `TypeError` for a non-boolean. |
|
|
337
|
+
| `resetNitroStorageMock()` | Clear all mock data and observers, restore protected-data availability to `true`, and drop every `onProtectedDataAvailable` listener. |
|
|
338
|
+
|
|
322
339
|
## Enums
|
|
323
340
|
|
|
324
341
|
```ts
|
|
@@ -348,6 +365,8 @@ enum BiometricLevel {
|
|
|
348
365
|
Common public types:
|
|
349
366
|
|
|
350
367
|
- `Storage`
|
|
368
|
+
- `SecureAccessControlMigrationOptions`
|
|
369
|
+
- `SecureAccessControlMigrationResult`
|
|
351
370
|
- `Validator<T>`
|
|
352
371
|
- `ExpirationConfig`
|
|
353
372
|
- `StorageItem<T>`
|
|
@@ -37,8 +37,42 @@ The runner supplies a fresh UUID as `RUN_ID` to each replay. The persistence
|
|
|
37
37
|
flow writes one `RUN_ID`-scoped Disk key, closes and relaunches the installed
|
|
38
38
|
app with the same ID, compares the stored value with the expected value, and
|
|
39
39
|
deletes that key. Other smoke and lab cases use reserved QA keys and clean only
|
|
40
|
-
those keys.
|
|
41
|
-
|
|
40
|
+
those keys. Only the final `clear-all` suite clears whole scopes.
|
|
41
|
+
|
|
42
|
+
The `clear-all` suite (`e2e/qa-clear-all.ad`) seeds one reserved key in Memory,
|
|
43
|
+
Disk, and Secure, then calls `storage.clearAll()`. This wipes all example
|
|
44
|
+
storage in every scope on the target install, not only QA keys. It is the last
|
|
45
|
+
suite in the manifest, and the runner passes suites to `agent-device test` in
|
|
46
|
+
manifest order, so it runs after every other suite. Run it only on a disposable
|
|
47
|
+
example install. Use `--flow` to skip it when the install holds data you need.
|
|
48
|
+
|
|
49
|
+
The `api-extended` suite (`e2e/qa-api-extended.ad`) opens a run-on-load screen
|
|
50
|
+
and asserts its results through the on-screen `e2e-ext-results` label, which
|
|
51
|
+
joins one `;`-terminated token per check. It covers item `merge` and
|
|
52
|
+
`setOrDelete`, Set `toggle` and `values`, `clearGroup`, TTL `onExpired` and
|
|
53
|
+
`subscribeExpired`, Secure metadata and export inventory, default access
|
|
54
|
+
control, coalesced Secure writes, the `invalid_key` error, scoped metrics,
|
|
55
|
+
native Disk key events, the React hooks, and `createSecureAuthStorage` TTL. It
|
|
56
|
+
uses reserved `__e2e_ext_`, `__e2e_inv_`, and `__e2e_auth` keys and restores the
|
|
57
|
+
default access control to `WhenUnlocked` after each access-control check. Its
|
|
58
|
+
passcode access-control row is a gating probe for later iOS biometric work: the
|
|
59
|
+
flow asserts only that a `passcode-acl=` result rendered. A device without a
|
|
60
|
+
passcode can reject that write, and the row is not a pass for passcode-protected
|
|
61
|
+
storage.
|
|
62
|
+
|
|
63
|
+
The `biometric-ios` suite (`e2e/qa-biometric-ios.ad`) runs only with
|
|
64
|
+
`--platform ios`; its manifest entry sets `platforms: ["ios"]`, the runner skips
|
|
65
|
+
it for Android, and `--flow biometric-ios` with `--platform android` fails before
|
|
66
|
+
any device command. It enrolls simulator Face ID, opens the run-on-load
|
|
67
|
+
`e2e-biometric` route with `RUN_ID`, and writes one `__e2e_bio_<RUN_ID>` item
|
|
68
|
+
with `biometric: true` and the default level. The `e2e-biometric-results` label
|
|
69
|
+
then shows the seed and metadata tokens and `bio:read=prompting;`. After a fixed
|
|
70
|
+
1500 ms wait, `settings faceid match` resolves the blocked read, and the flow
|
|
71
|
+
expects `bio:read=value-matched;` and `bio:cleanup=missing;` before it unenrolls
|
|
72
|
+
Face ID. Cleanup calls `storage.clearBiometric()`, which removes every
|
|
73
|
+
biometric Secure item in the example install. This is simulator evidence only,
|
|
74
|
+
not Secure Enclave hardware evidence. Android biometrics and iOS nonmatch or
|
|
75
|
+
cancel outcomes remain pending.
|
|
42
76
|
|
|
43
77
|
Each run uses a unique session and an artifact directory under the OS temporary
|
|
44
78
|
directory. `agent-device test` closes each attempt session itself, including
|
|
@@ -50,7 +84,8 @@ itself is not sufficient. Smoke and integrity rows are asserted through the
|
|
|
50
84
|
on-screen `smoke-results` and `e2e-integrity-results` labels because
|
|
51
85
|
agent-device `.ad` waits only see on-screen elements. The keychain suite runs one no-prompt Secure
|
|
52
86
|
roundtrip and keeps biometric, lock, corruption, and hardware-backed checks
|
|
53
|
-
explicitly pending
|
|
87
|
+
explicitly pending; only the iOS simulator Face ID match path is asserted, by
|
|
88
|
+
`biometric-ios`. The smoke test's constructed `storage_full` error is read
|
|
54
89
|
through the public `/testing` entrypoint; it proves error-code classification
|
|
55
90
|
at that test adapter boundary only.
|
|
56
91
|
|
|
@@ -62,8 +97,9 @@ Disk, and Secure.
|
|
|
62
97
|
|
|
63
98
|
Native replay rows identify the public runtime platform and backend capability,
|
|
64
99
|
then assert values returned through the default package entrypoint. This
|
|
65
|
-
supports installed-app Disk and Secure behavior claims for that target.
|
|
66
|
-
|
|
100
|
+
supports installed-app Disk and Secure behavior claims for that target. Apart
|
|
101
|
+
from the iOS simulator Face ID match in `biometric-ios`, it does not prove
|
|
102
|
+
hardware-backed keys, biometrics, locked-device behavior, corruption
|
|
67
103
|
recovery, or a native Disk/Secure write failure. Those checks need controlled
|
|
68
104
|
native or hardware prerequisites and remain pending in the coverage manifest.
|
|
69
105
|
Unit tests for an injected adapter boundary are useful logic evidence, but they
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
#include "../core/NativeStorageAdapter.hpp"
|
|
4
4
|
#include <atomic>
|
|
5
|
+
#include <functional>
|
|
6
|
+
#include <optional>
|
|
7
|
+
#include <memory>
|
|
5
8
|
#include <mutex>
|
|
6
9
|
#include <unordered_set>
|
|
7
10
|
|
|
@@ -9,7 +12,10 @@ namespace NitroStorage {
|
|
|
9
12
|
|
|
10
13
|
class IOSStorageAdapterCpp : public NativeStorageAdapter {
|
|
11
14
|
public:
|
|
15
|
+
using ProtectedDataReader = std::function<std::optional<bool>()>;
|
|
16
|
+
|
|
12
17
|
IOSStorageAdapterCpp();
|
|
18
|
+
explicit IOSStorageAdapterCpp(ProtectedDataReader protectedDataReader);
|
|
13
19
|
~IOSStorageAdapterCpp() override;
|
|
14
20
|
|
|
15
21
|
void setDisk(const std::string& key, const std::string& value) override;
|
|
@@ -48,7 +54,13 @@ public:
|
|
|
48
54
|
bool hasSecureBiometric(const std::string& key) override;
|
|
49
55
|
void clearSecureBiometric() override;
|
|
50
56
|
|
|
57
|
+
bool isProtectedDataAvailable() override;
|
|
58
|
+
std::function<void()> addProtectedDataAvailableListener(std::function<void()> listener) override;
|
|
59
|
+
|
|
51
60
|
private:
|
|
61
|
+
struct ProtectedDataState;
|
|
62
|
+
std::shared_ptr<ProtectedDataState> protectedData_;
|
|
63
|
+
|
|
52
64
|
int accessControlLevel_ = 0;
|
|
53
65
|
std::string keychainAccessGroup_;
|
|
54
66
|
mutable std::mutex secureKeysMutex_;
|