diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index d234439c..ba7a9fc7 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -43,7 +43,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '22' + node-version: '24' cache: yarn - name: Install dependencies (yarn) @@ -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 diff --git a/.github/workflows/ios-build.yml b/.github/workflows/ios-build.yml index f673ed91..2f558207 100644 --- a/.github/workflows/ios-build.yml +++ b/.github/workflows/ios-build.yml @@ -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 @@ -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 @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bf4a034..f33ce84e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 10b1d995..8c681cfd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '22' + node-version: '24' cache: yarn - name: Install dependencies diff --git a/.prettierrc.js b/.prettierrc.js deleted file mode 100644 index 853d3be7..00000000 --- a/.prettierrc.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - singleQuote: true, - semi: true, - tabWidth: 2, - trailingComma: 'es5', - useTabs: false, - quoteProps: 'consistent', -}; diff --git a/.release-it.json b/.release-it.json index da4ffc69..899b67a0 100644 --- a/.release-it.json +++ b/.release-it.json @@ -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" + } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a08f44..893840ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, 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__v`) 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 diff --git a/README.md b/README.md index 8ab03dde..2b125254 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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() { @@ -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 @@ -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: @@ -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 }) @@ -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 ( +