Skip to content

Commit c0c3af9

Browse files
committed
Add docs and tweak example + tsconfig
Add several new documentation pages (docs/ARCHITECTURE.md, EXPO.md, MIGRATION.md, PERFORMANCE.md, THREAT_MODEL.md) covering architecture, Expo usage, migration notes, performance guidance, and threat model. In example/src/components/StorageCard.tsx memoize the storage options (add useMemo import) so useSecureStorage receives a stable object for silent enumeration and to avoid unnecessary re-renders. Enable declarationMap in tsconfig.json to emit declaration maps for built types.
1 parent 1de115a commit c0c3af9

7 files changed

Lines changed: 366 additions & 2 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Architecture
2+
3+
```
4+
┌──────────────────────────────────────────────────────────────────────┐
5+
│ Public API │
6+
│ ├── react-native-sensitive-info (storage, errors, types) │
7+
│ ├── react-native-sensitive-info/hooks (React hooks) │
8+
│ └── react-native-sensitive-info/errors (typed errors only) │
9+
└────────────────────────────────────────────┬─────────────────────────┘
10+
11+
┌────────────────────────────────────────────▼─────────────────────────┐
12+
│ TS layer (src/) │
13+
│ • core/storage.ts ─ thin wrapper, normalizes options + classifies │
14+
│ errors │
15+
│ • internal/options ─ defaults, service resolution │
16+
│ • internal/native ─ lazy Nitro hybrid handle (Expo Go detection) │
17+
│ • internal/validate ─ TS-side input contracts (key/service/value) │
18+
│ • internal/errors ─ marker-based error utilities │
19+
│ • errors.ts ─ typed error classes + predicates │
20+
│ • hooks/ ─ stable, memoized hook surface │
21+
└────────────────────────────────────────────┬─────────────────────────┘
22+
│ Nitro JSI bridge
23+
┌────────────────────────────────────────────▼─────────────────────────┐
24+
│ Native layer │
25+
│ • iOS ─ Swift / Keychain / Secure Enclave / CryptoKit │
26+
│ • Android─ Kotlin / Keystore / StrongBox / AES-GCM │
27+
└──────────────────────────────────────────────────────────────────────┘
28+
```
29+
30+
## Naming conventions
31+
32+
### Service & key
33+
34+
- `service` is a logical namespace (reverse-DNS recommended: `com.example.auth`). Defaults to
35+
the bundle identifier when available, otherwise `'default'`.
36+
- `key` is the entry identifier within a service. Must be a non-empty UTF-8 string ≤ 1024 bytes.
37+
- Combined storage key on Android: `<service>::<key>`. On iOS: Keychain `service` attribute is
38+
set to `service` and `account` to `key`.
39+
40+
### Keystore aliases (Android)
41+
42+
Each `service` owns a master key under the alias `rnsensitiveinfo.<service>.v<n>` where `n` is
43+
the active key version. `rotateKeys` increments `n` and either lazily re-encrypts on next access
44+
or eagerly walks the existing entries when `reEncryptEagerly: true`.
45+
46+
### Versions
47+
48+
- `keyVersion` is stored in `StorageMetadata`. Legacy entries default to `0` and are
49+
opportunistically upgraded on read.
50+
- `integrityTag` is a base64 HMAC-SHA256 over metadata + ciphertext, signed with a derived
51+
subkey of the master.
52+
53+
## Cache locations
54+
55+
| Cache | Where | Lifetime |
56+
| ------------------- | ------------------------------------ | ------------------------------------ |
57+
| Nitro instance | `src/internal/native.ts` module-scope | App process |
58+
| Stable options | Per-hook `useRef` keyed by deepEqual | Component lifetime |
59+
| Service resolution | `src/internal/options.ts` | Recomputed on every call (cheap) |
60+
| Key version (native)| Keystore / Keychain | Persistent until `clearService` |
61+
62+
## Tree-shaking
63+
64+
The package sets `"sideEffects": false` and ships ESM via subpath exports. Hooks live behind
65+
`react-native-sensitive-info/hooks` so apps that only use the imperative API never pay for the
66+
hook bundle. Errors are also re-exported from `/errors` for the same reason.

docs/EXPO.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Expo
2+
3+
## Compatibility matrix
4+
5+
| Library version | Expo SDK | React Native | Notes |
6+
| --------------- | -------- | ------------ | -------------------------------------- |
7+
| `6.0.x` | 52+ | 0.80–0.85 | Nitro Modules, New Architecture only. |
8+
| `5.6.x` | 49–51 | 0.71–0.74 | Legacy bridge build, maintenance mode. |
9+
10+
`react-native-sensitive-info` ships native code, so it cannot run inside **Expo Go**. You need
11+
either a **custom Dev Client** (`npx expo run:ios` / `npx expo run:android`) or an **EAS Build**.
12+
13+
When the native module is unavailable at runtime (typically Expo Go), every API throws a
14+
`SensitiveInfoError` with a hint that points to the Dev Client / EAS workflow.
15+
16+
## Installation
17+
18+
```bash
19+
npx expo install react-native-sensitive-info
20+
```
21+
22+
Add the plugin to your `app.json` / `app.config.ts`:
23+
24+
```json
25+
{
26+
"expo": {
27+
"plugins": [
28+
[
29+
"react-native-sensitive-info",
30+
{
31+
"faceIDPermission": "Authenticate to unlock your account.",
32+
"enableNewArchitecture": true
33+
}
34+
]
35+
]
36+
}
37+
}
38+
```
39+
40+
### Plugin options
41+
42+
| Prop | Type | Default | Effect |
43+
| ----------------------- | --------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
44+
| `faceIDPermission` | `string` | `"Authenticate to access your secure data."` | Written to `NSFaceIDUsageDescription` if missing. A pre-existing value in your Info.plist is preserved. |
45+
| `faceIDPermission` | `null` || Skips the Info.plist modifier entirely (use when another plugin owns the key). |
46+
| `enableNewArchitecture` | `boolean` | `true` | Writes `newArchEnabled` (Android) and `RCT_NEW_ARCH_ENABLED` (iOS) flags. |
47+
48+
The plugin also adds the following Android permissions automatically:
49+
50+
- `android.permission.USE_BIOMETRIC` (API 28+)
51+
- `android.permission.USE_FINGERPRINT` with `android:maxSdkVersion="28"` (legacy fallback)
52+
53+
## Generating the native projects
54+
55+
After adding the plugin:
56+
57+
```bash
58+
npx expo prebuild --clean # Regenerates ios/ and android/ with the plugin applied
59+
npx expo run:ios # or run:android
60+
```
61+
62+
For EAS Build:
63+
64+
```bash
65+
eas build --profile development --platform ios
66+
```
67+
68+
## Troubleshooting
69+
70+
- **"native module is not available"** at runtime — you are running in Expo Go. Switch to a
71+
Dev Client.
72+
- **Face ID prompt has no usage string** — confirm `NSFaceIDUsageDescription` is in the
73+
rendered `ios/<app>/Info.plist` after `prebuild`.
74+
- **Biometric APIs return `software` security level on Android** — confirm the device is
75+
enrolled and that `USE_BIOMETRIC` was written to the rendered `AndroidManifest.xml`.

docs/MIGRATION.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Migration: 5.6 → 6.x
2+
3+
`react-native-sensitive-info` 6 is a from-scratch rewrite on top of [Nitro Modules][nitro] and
4+
the React Native New Architecture. The public API is intentionally narrower and more typed than
5+
5.6.
6+
7+
[nitro]: https://nitro.margelo.com/
8+
9+
## Breaking changes at a glance
10+
11+
| 5.6 | 6.x |
12+
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
13+
| Bridge module, Old Architecture only | Nitro hybrid object, **New Architecture required** |
14+
| `setItem(key, value, options)` returns void | `setItem(key, value, options)` returns `Promise<StorageMetadata>` |
15+
| `getItem(key, options)` returns the raw value or `null` | `getItem(key, options)` returns `SensitiveInfoItem \| null` (`{ key, service, value, metadata }`) |
16+
| `getAllItems(options)` returns `Record<string, string>` | `getAllItems(options)` returns `SensitiveInfoItem[]` |
17+
| `deleteItem(key, options)` returns void | `deleteItem(key, options)` returns `Promise<boolean>` (`true` when removed) |
18+
| Errors are plain `Error` instances | Typed `SensitiveInfoError` subclasses + `is*Error` predicates |
19+
| `kSecAccessControl*` strings on iOS | `accessControl: 'secureEnclaveBiometry' \| 'biometryCurrentSet' \| 'biometryAny' \| 'devicePasscode' \| 'none'` |
20+
| Android `keystore` config object | `accessControl` only — backend is selected automatically (StrongBox → Keystore → EncryptedSharedPreferences) |
21+
| No metadata | `StorageMetadata` returned with every read/write (`securityLevel`, `backend`, `keyVersion`, `integrityTag`) |
22+
| No key rotation | `rotateKeys()` + `getKeyVersion()` |
23+
| No React hooks | First-party hooks via `react-native-sensitive-info/hooks` |
24+
25+
## Step-by-step
26+
27+
### 1. Update peer requirements
28+
29+
- React Native ≥ 0.80 with **New Architecture enabled**.
30+
- Expo users: SDK 52+ and a custom Dev Client / EAS Build (Expo Go is not supported).
31+
32+
### 2. Replace value-only reads
33+
34+
```diff
35+
- const token = await getItem('token', { sharedPreferencesName: 'auth' })
36+
+ const item = await getItem('token', { service: 'auth' })
37+
+ const token = item?.value ?? null
38+
```
39+
40+
> `sharedPreferencesName` (Android) and `keychainService` (iOS) are unified as `service`.
41+
42+
### 3. Use typed errors
43+
44+
```diff
45+
- try { await getItem('k', opts) }
46+
- catch (e) { if (e.message.includes('cancel')) return }
47+
+ import { isAuthenticationCanceledError } from 'react-native-sensitive-info'
48+
+ try { await getItem('k', opts) }
49+
+ catch (e) { if (isAuthenticationCanceledError(e)) return }
50+
```
51+
52+
Available predicates: `isNotFoundError`, `isAuthenticationCanceledError`,
53+
`isIntegrityViolationError`, `isKeyInvalidatedError`, `isRotationFailedError`,
54+
`isInvalidArgumentError`.
55+
56+
### 4. Migrate access-control flags
57+
58+
| 5.6 (`kSecAccessControl…`) | 6.x (`accessControl`) |
59+
| ------------------------------------------------------------ | ---------------------------- |
60+
| `kSecAccessControlBiometryCurrentSet` + Secure Enclave class | `secureEnclaveBiometry` |
61+
| `kSecAccessControlBiometryCurrentSet` | `biometryCurrentSet` |
62+
| `kSecAccessControlBiometryAny` | `biometryAny` |
63+
| `kSecAccessControlDevicePasscode` | `devicePasscode` |
64+
| (no policy) | `none` |
65+
66+
### 5. Replace `setInvalidatedByBiometricEnrollment` etc.
67+
68+
These were Apple-specific opt-outs. In 6.x, biometric-bound entries that the OS invalidates
69+
(re-enrollment, biometric reset) raise `KeyInvalidatedError` deterministically. Catch it and
70+
re-prompt the user to set up the secret again.
71+
72+
### 6. Adopt hooks (optional)
73+
74+
```ts
75+
import { useSecret } from 'react-native-sensitive-info/hooks'
76+
77+
const { data, error, saveSecret, deleteSecret } = useSecret('token', {
78+
service: 'auth',
79+
includeValue: true,
80+
})
81+
```
82+
83+
## Data migration
84+
85+
5.6 → 6.x is **not** wire-compatible. Existing entries written by 5.6 are not readable by 6.x
86+
because the metadata envelope is different. To migrate live data, ship a one-time migration that
87+
reads with a vendored 5.6 helper and re-writes via 6.x `setItem`. For most apps the safer answer
88+
is "let users re-authenticate once" — secrets are short-lived by design.

docs/PERFORMANCE.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Performance
2+
3+
## Bundle policy
4+
5+
- `"sideEffects": false` — every public surface tree-shakes cleanly.
6+
- Subpath exports keep the imperative API, the hooks, and the error classes in **separate**
7+
ESM entry points so apps only pay for what they import.
8+
- The TypeScript-side dependency graph is intentionally flat. The whole runtime (errors,
9+
options, native handle, validation, storage) is a few hundred lines of JS plus the Nitro
10+
bridge.
11+
12+
| Import | Approximate min+gz size† |
13+
| --------------------------------------------- | ------------------------ |
14+
| `react-native-sensitive-info` | ~3.0 KB |
15+
| `react-native-sensitive-info/hooks` | ~4.5 KB |
16+
| `react-native-sensitive-info/errors` | ~1.0 KB |
17+
18+
> † Numbers are a rough order-of-magnitude on RN Metro output; verify with your own bundler.
19+
20+
## Hooks
21+
22+
Every public hook follows the same recipe to keep re-renders cheap:
23+
24+
1. Inline option literals are **deep-equal cached** through `useStableOptions`. Passing a fresh
25+
object each render does not invalidate the underlying `useAsync` / `useMutation`.
26+
2. `useReducer` drives the lifecycle so `setState` cannot tear (loading + data + error always
27+
commit together).
28+
3. The returned object is wrapped in `useMemo` with stable identity per state transition.
29+
4. Stable empty arrays (e.g. `EMPTY_ITEMS`) are frozen once at module scope so consumers can
30+
safely use referential equality.
31+
32+
That means you can pass result objects to `React.memo` children or to a `Context.Provider`
33+
without paying for spurious re-renders.
34+
35+
## React Compiler (Babel plugin)
36+
37+
The hooks are written so that the React Compiler **does not** need to see the source to keep
38+
them stable. We don't ship Compiler output; the optimization is done by hand and stays explicit.
39+
This keeps the runtime debuggable and avoids forcing consumers onto the Compiler.
40+
41+
If you do enable the Compiler app-wide, the hooks remain correct: they don't rely on memo
42+
identity behaviour the Compiler would change.
43+
44+
## Native call overhead
45+
46+
- iOS: each call is a single Keychain transaction. The Secure Enclave path adds ~20–60 ms for
47+
biometric prompts (user-driven).
48+
- Android: Keystore unwrap is on the critical path. StrongBox-backed keys add ~30–80 ms for
49+
the IPC round-trip. `EncryptedSharedPreferences` fallback is ~3–5 ms per read.
50+
51+
Always avoid calling `getItem` in tight render loops — fetch once, hoist into state.
52+
53+
## Recommended patterns
54+
55+
- **List metadata, fetch on demand.** Use `getAllItems({ includeValues: false })` (or
56+
`useSecureStorage` with the same flag) to render a list cheaply, and only call `getItem` when
57+
the user explicitly asks for the value.
58+
- **Batch on rotation.** `rotateKeys({ reEncryptEagerly: true })` walks all entries in one
59+
native pass. Doing it lazily per-read is fine but spreads cost across the app's lifetime.
60+
- **Skip the biometric on enumeration.** iOS will not prompt for `hasItem` /
61+
`getAllItems({ includeValues: false })` — keep enumeration silent and reserve prompts for
62+
reads that actually need plaintext.

0 commit comments

Comments
 (0)