Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/android-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: '22'
node-version: '24'
cache: yarn

- name: Install dependencies (yarn)
Expand All @@ -55,11 +55,11 @@ jobs:
- name: Generate Nitro modules (codegen)
run: yarn codegen

- name: Setup JDK 17
- name: Setup JDK 21
uses: actions/setup-java@v5
with:
distribution: zulu
java-version: '17'
java-version: '21'
cache: gradle

- name: Cache Gradle
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/ios-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
node-version: '24'
cache: 'yarn'
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
Expand All @@ -72,7 +72,7 @@ jobs:
- name: Setup Ruby (bundle)
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
ruby-version: '3.4'
bundler-cache: true
working-directory: example/ios

Expand Down Expand Up @@ -103,6 +103,6 @@ jobs:
-scheme SensitiveInfoExample \
-sdk iphonesimulator \
-configuration Debug \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-destination 'platform=iOS Simulator,name=iPhone 17' \
build \
CODE_SIGNING_ALLOWED=NO | xcpretty
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
node-version: '24'
cache: 'yarn'

- name: Install npm dependencies (yarn)
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: '22'
node-version: '24'
cache: yarn

- name: Install dependencies
Expand Down
8 changes: 0 additions & 8 deletions .prettierrc.js

This file was deleted.

44 changes: 22 additions & 22 deletions .release-it.json
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
{
"git": {
"commitMessage": "chore(release): v${version}",
"tagName": "v${version}",
"requireCleanWorkingDir": true,
"push": true,
"requireUpstream": false
},
"npm": {
"publish": true,
"verifyAccess": true,
"tokenRef": "NPM_TOKEN"
},
"github": {
"release": true,
"releaseName": "v${version}",
"tokenRef": "GITHUB_TOKEN"
},
"hooks": {
"before:init": ["npm run typecheck", "npm run build", "npm run codegen"],
"after:bump": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s -r 0 && git add CHANGELOG.md",
"after:release": "git push --follow-tags"
}
"git": {
"commitMessage": "chore(release): v${version}",
"tagName": "v${version}",
"requireCleanWorkingDir": true,
"push": true,
"requireUpstream": false
},
"npm": {
"publish": true,
"verifyAccess": true,
"tokenRef": "NPM_TOKEN"
},
"github": {
"release": true,
"releaseName": "v${version}",
"tokenRef": "GITHUB_TOKEN"
},
"hooks": {
"before:init": ["npm run typecheck", "npm run build", "npm run codegen"],
"after:bump": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s -r 0 && git add CHANGELOG.md",
"after:release": "git push --follow-tags"
}
}
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,38 @@
## Unreleased (6.0.0-rc.13)

### Features

* **rotation:** Add versioned key rotation via `rotateKeys()` and `getKeyVersion()` with lazy re-encryption on read. New `useKeyRotation` hook exposes the same flow declaratively.
* **security hardening:** Defense-in-depth pass — non-breaking, applied transparently to new writes and via lazy upgrade on rotation:
- HMAC-SHA256 integrity tag bound to every entry's metadata + ciphertext, surfaced on `StorageMetadata.integrityTag`. Tampering with SharedPreferences/Keychain attributes now raises `IntegrityViolationError` (`E_INTEGRITY_VIOLATION`) before any biometric prompt is shown.
- AES-GCM AAD on Android binds ciphertext to `service|key|v<version>`, defeating cross-entry swap attacks.
- `setUnlockedDeviceRequired(true)` on every Android Keystore key (API 28+), mirroring iOS's `kSecAttrAccessibleWhenUnlocked` semantics.
- Plaintext byte buffers are zeroized after use on both platforms.
- Constant-time HMAC comparison via `MessageDigest.isEqual` / manual `UInt8` XOR fold.
- Backwards compatible: entries written by earlier versions decode without verification and are upgraded on the next write or rotation.
* **errors:** New typed error classes (`SensitiveInfoError`, `NotFoundError`, `AuthenticationCanceledError`, `IntegrityViolationError`, `KeyInvalidatedError`, `RotationFailedError`) with `code` discriminants and `instanceof` predicates. Importable from the `react-native-sensitive-info/errors` subpath.
* **tree-shaking:** `"sideEffects": false` everywhere; the package now publishes three focused subpath entries (`.`, `/hooks`, `/errors`). The default export has been removed — import only the helpers you use.
* **nitro 0.35:** Regenerated against `nitrogen@0.35.5` and `react-native-nitro-modules@0.35.5`.
* **tooling:** Migrated linting/formatting from ESLint + Prettier to **Biome 2**. Single config at `biome.json`, faster CI runs.

### Refactor (KISS · DRY · SRP)

* Introduced `useAsyncQuery` (read-only hooks) and `useMutation` (mutation hooks) primitives. `useHasSecret`, `useSecretItem`, `useSecureOperation`, `useKeyRotation`, and `useSecureStorage` now compose the same lifecycle/abort/error-handling pipeline — no duplicated state machines.
* `useSecureStorage` shrunk from ~230 LOC to ~180 LOC and reuses the shared abort + auth-cancel semantics; behaviour is unchanged.
* Test fixtures consolidated in `src/__tests__/__mocks__/fixtures.ts` (`buildTestItem`, `buildTestMetadata`).
* Removed redundant re-exports from `src/internal/errors.ts`.

### Breaking changes

* The default export is gone. Use named imports: `import { setItem } from 'react-native-sensitive-info'`.
* React hooks are no longer re-exported from the package root — import them from `react-native-sensitive-info/hooks`.

### Notes

* **iOS rotation** updates the Keychain metadata via `SecItemUpdate`, preserving the original access-control attributes while bumping `keyVersion`.
* **Android rotation** mints a fresh per-entry Keystore alias (`SensitiveInfo_<hash>_v<version>`) during lazy or eager re-encryption and deletes the stale alias after a successful rewrite.
* Version state lives in a non-secret registry (`SharedPreferences` on Android, `UserDefaults` on iOS). Delete the app's data to reset.

## [6.0.0-rc.12](https://github.com/mcodex/react-native-sensitive-info/compare/v6.0.0-rc.11...v6.0.0-rc.12) (2025-12-16)

### Features
Expand Down
107 changes: 105 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ Modern secure storage for React Native, powered by Nitro Modules. Version 6 ship
- [📚 API reference](#-api-reference)
- [🔐 Access control & metadata](#-access-control--metadata)
- [❗ Error handling](#-error-handling)
- [🔁 Key rotation](#-key-rotation)
- [🛡️ Security model](#-security-model)
- [🌳 Tree-shaking](#-tree-shaking)
- [🧪 Simulators and emulators](#-simulators-and-emulators)
- [📈 Performance benchmarks](#-performance-benchmarks)
- [🎮 Example application](#-example-application)
Expand Down Expand Up @@ -150,7 +153,7 @@ import { Text, View, ActivityIndicator } from 'react-native'
import {
useSecureStorage,
useSecurityAvailability,
} from 'react-native-sensitive-info'
} from 'react-native-sensitive-info/hooks'

// Use hooks directly in any component - no provider needed!
function YourComponent() {
Expand Down Expand Up @@ -193,6 +196,7 @@ function YourComponent() {
| `useSecret()` | Single secret + mutations | `{ data, isLoading, error, saveSecret, deleteSecret, refetch }` |
| `useHasSecret()` | Check if secret exists (lightweight) | `{ data (boolean), isLoading, error, refetch }` |
| `useSecurityAvailability()` | Query device capabilities (cached) | `{ data, isLoading, error, refetch }` |
| `useKeyRotation()` | Rotate the master key for a service | `{ lastResult, error, isRotating, rotate, readVersion }` |

### Best practices

Expand Down Expand Up @@ -220,6 +224,18 @@ function YourComponent() {

For comprehensive examples and advanced patterns, see [`HOOKS.md`](./HOOKS.md).

### 🧱 Hook architecture (DRY · KISS · SRP)

Every hook in this package is a thin choreography layer over three internal primitives, so adding or auditing a hook stays a single-file change:

| Primitive | Responsibility |
| --- | --- |
| `useAsyncLifecycle` | Mount tracking + `AbortController` plumbing — _one job, no React state of its own_. |
| `useAsync` / `useAsyncQuery` | The shared "stable options → strip `skip` → memoize → fetch" recipe used by every read-only hook (`useHasSecret`, `useSecretItem`, `useSecret`, `useSecureStorage`, `useSecurityAvailability`). |
| `useMutation` | The imperative state machine (loading + error + auth-cancel handling) reused by every mutation-style hook (`useSecureOperation`, `useKeyRotation`, plus the `saveSecret`/`removeSecret`/`clearAll` helpers in `useSecureStorage`). |

Net effect: the data-fetching hooks are 25–35 lines each, mutations are ~10 lines, and the abort/cancel/error contract is identical across the surface — there is no place where a bug fix has to be repeated.

## ❗ Error handling

Every public hook returns failures as `HookError` instances. Besides `message`, each error carries:
Expand All @@ -232,7 +248,7 @@ Biometric or device-credential prompts cancelled by the user now surface as a fr

```tsx
import { Text } from 'react-native'
import { useSecureStorage } from 'react-native-sensitive-info'
import { useSecureStorage } from 'react-native-sensitive-info/hooks'

function SecretsList() {
const { items, error } = useSecureStorage({ service: 'auth', includeValues: true })
Expand Down Expand Up @@ -261,6 +277,93 @@ function SecretsList() {
> [!TIP]
> When using the imperative API, look for the `[E_AUTH_CANCELED]` marker in the thrown error message to detect cancellations.

## 🔁 Key rotation

The library supports **versioned master keys** with lazy re-encryption. Each stored entry is tagged with the `keyVersion` that produced its ciphertext. Calling `rotateKeys()` bumps the active version; subsequent reads transparently re-encrypt entries that were stored under older versions.

```tsx
import { rotateKeys, getKeyVersion } from 'react-native-sensitive-info'

// Lazy rotation — new writes use v+1, reads upgrade older entries as they happen
await rotateKeys({ service: 'auth' })

// Eager rotation — walks every entry in the service and re-encrypts in one go
await rotateKeys({ service: 'auth', reEncryptEagerly: true })

// Inspect the currently active version for telemetry
const version = await getKeyVersion({ service: 'auth' })
```

Or with the hook:

```tsx
import { useKeyRotation } from 'react-native-sensitive-info/hooks'

function RotationButton() {
const { rotate, isRotating, lastResult, error } = useKeyRotation({
service: 'auth',
})

return (
<Button
title={isRotating ? 'Rotating…' : 'Rotate master key'}
onPress={rotate}
disabled={isRotating}
/>
)
}
```

## 🛡️ Security model

| Concern | Android | iOS / Apple platforms |
| --- | --- | --- |
| Master key | Android Keystore (`AES/GCM`, StrongBox when available) | Secure Enclave-gated (P-256) + AES-GCM |
| Authentication | BiometricPrompt (Class 3 preferred), device credential fallback | LAContext / Face ID / Touch ID / Optic ID |
| At-rest integrity | AES-GCM tag **+** HMAC-SHA256 metadata tag (Keystore-bound) | AES-GCM tag **+** HMAC-SHA256 metadata tag (Keychain-stored, after-first-unlock) |
| Replay / swap defense | AES-GCM AAD bound to `service\|key\|v<version>` | Keychain `kSecAttrService` + `kSecAttrAccount` binding |
| Device-state gating | `setUnlockedDeviceRequired(true)` on every key (API 28+) | `kSecAttrAccessibleWhenUnlocked*` defaults |
| Plaintext lifetime | Buffers zeroized after encrypt/decrypt | `Data` buffers zeroized via `memset_s` |
| Key rotation | Versioned Keystore aliases, lazy re-encryption | Versioned Keychain metadata, lazy re-wrap (preserves original access control) |
| Error classification | Typed `SensitiveInfoError` subclasses via `/errors` subpath | Same |

> **Tamper detection:** every read recomputes the HMAC over the persisted `(service, key, version, accessControl, securityLevel, timestamp, ciphertext, iv)` tuple. A mismatch raises `IntegrityViolationError` (`E_INTEGRITY_VIOLATION`) **before** any biometric prompt fires, so spoofed entries can never trigger user authentication. Entries written by older library versions (no `integrityTag`) are accepted on first read and upgraded on the next write or rotation.

Typed errors can be imported from the `/errors` subpath for tree-shakeable error handling:

```tsx
import {
isNotFoundError,
isAuthenticationCanceledError,
isIntegrityViolationError,
isKeyInvalidatedError,
} from 'react-native-sensitive-info/errors'

try {
await getItem('token', { service: 'auth' })
} catch (error) {
if (isAuthenticationCanceledError(error)) return
if (isKeyInvalidatedError(error)) {
// The hardware key was invalidated (e.g. biometrics re-enrolled).
// Delete the affected entry and ask the user to re-enter.
await deleteItem('token', { service: 'auth' })
}
throw error
}
```

## 🌳 Tree-shaking

Every entry point is side-effect-free (`"sideEffects": false`) and split into focused subpaths:

| Import | Contents |
| --- | --- |
| `react-native-sensitive-info` | `setItem`, `getItem`, `hasItem`, `deleteItem`, `getAllItems`, `clearService`, `getSupportedSecurityLevels`, `rotateKeys`, `getKeyVersion`, type exports |
| `react-native-sensitive-info/hooks` | Every React hook (`useSecret`, `useSecureStorage`, `useKeyRotation`, …) |
| `react-native-sensitive-info/errors` | Typed error classes + `instanceof` predicates |

There is **no default export** — import only the helpers you use and modern bundlers (Metro, Webpack, Rollup, esbuild) will drop the rest.

## Imperative API

```tsx
Expand Down
Loading
Loading