@sigx/lynx-secure-storage 0.4.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Andreas Ekdahl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,191 @@
1
+ # @sigx/lynx-secure-storage
2
+
3
+ Encrypted at-rest key-value storage for sigx-lynx — iOS Keychain, Android Keystore + `EncryptedSharedPreferences`.
4
+
5
+ For plaintext settings (theme, last-used tab, feature flags) use [`@sigx/lynx-storage`](../lynx-storage). Use this package for **credentials, refresh tokens, PII, recovery keys** — anything that must survive a casual filesystem dump or backup exfiltration.
6
+
7
+ Pairs with [`@sigx/lynx-biometric`](../lynx-biometric) when you also need an explicit "unlock the app" gate; the `requireBiometric` option here gates the *individual key* via the OS Keychain / Keystore.
8
+
9
+ - **iOS**: `kSecClassGenericPassword` items via the Keychain Services API. `kSecAccessControlBiometryCurrentSet` for biometric-gated keys; `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` otherwise — items are never included in iCloud / iTunes backups.
10
+ - **Android**: AES-256-GCM via the Android Keystore. Non-biometric keys land in `EncryptedSharedPreferences` (`androidx.security:security-crypto`); biometric-gated keys use a per-key Keystore alias with `setUserAuthenticationRequired(true)` and a `BiometricPrompt.CryptoObject` on read.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pnpm add @sigx/lynx-secure-storage
16
+ ```
17
+
18
+ `sigx prebuild` auto-discovers the package, links the native module, adds the `androidx.security` + `androidx.biometric` dependencies, and adds `<uses-permission android:name="android.permission.USE_BIOMETRIC"/>` to the Android manifest.
19
+
20
+ ### Android Auto Backup
21
+
22
+ `EncryptedSharedPreferences` files are included in Android Auto Backup by default. The data is encrypted but the master key is device-bound, so restoring to a new device produces unreadable blobs that the next `get()` call will fail on. **Recommended:** exclude both of this module's prefs files from backup.
23
+
24
+ Create `android/app/src/main/res/xml/sigx_backup_rules.xml`:
25
+
26
+ ```xml
27
+ <?xml version="1.0" encoding="utf-8"?>
28
+ <full-backup-content>
29
+ <exclude domain="sharedpref" path="sigx_secure_storage_v1.xml" />
30
+ <exclude domain="sharedpref" path="sigx_secure_storage_biometric_v1.xml" />
31
+ </full-backup-content>
32
+ ```
33
+
34
+ And the Android 12+ equivalent at `android/app/src/main/res/xml/sigx_data_extraction_rules.xml`:
35
+
36
+ ```xml
37
+ <?xml version="1.0" encoding="utf-8"?>
38
+ <data-extraction-rules>
39
+ <cloud-backup>
40
+ <exclude domain="sharedpref" path="sigx_secure_storage_v1.xml" />
41
+ <exclude domain="sharedpref" path="sigx_secure_storage_biometric_v1.xml" />
42
+ </cloud-backup>
43
+ <device-transfer>
44
+ <exclude domain="sharedpref" path="sigx_secure_storage_v1.xml" />
45
+ <exclude domain="sharedpref" path="sigx_secure_storage_biometric_v1.xml" />
46
+ </device-transfer>
47
+ </data-extraction-rules>
48
+ ```
49
+
50
+ Wire both in your `AndroidManifest.xml`'s `<application>` element:
51
+
52
+ ```xml
53
+ <application
54
+ android:fullBackupContent="@xml/sigx_backup_rules"
55
+ android:dataExtractionRules="@xml/sigx_data_extraction_rules"
56
+ …>
57
+ ```
58
+
59
+ Biometric-gated keys are intrinsically safe — the Keystore alias can't be restored to a different device — but the encrypted blob's still useless on restore, so excluding both files keeps the user out of a confusing "stale ciphertext" state.
60
+
61
+ ## Usage
62
+
63
+ ```ts
64
+ import { SecureStorage } from '@sigx/lynx-secure-storage';
65
+
66
+ // Plain encrypted set/get — no biometric prompt.
67
+ await SecureStorage.set('refresh_token', refreshToken);
68
+ const value = await SecureStorage.get('refresh_token');
69
+
70
+ // Biometric-gated key.
71
+ await SecureStorage.set('access_token', accessToken, { requireBiometric: true });
72
+
73
+ // Reading a biometric-gated key triggers Face ID / BiometricPrompt.
74
+ const token = await SecureStorage.get('access_token', {
75
+ biometricPrompt: {
76
+ reason: 'Unlock your account', // iOS LAContext / Android subtitle
77
+ title: 'Acme Bank', // Android only — prompt title
78
+ },
79
+ });
80
+
81
+ // Cheap existence check (never decrypts, never prompts).
82
+ if (await SecureStorage.hasKey('access_token')) { /* … */ }
83
+
84
+ // Per-key delete + namespaced clear.
85
+ await SecureStorage.delete('refresh_token');
86
+ await SecureStorage.clear(); // wipes only THIS module's items
87
+ ```
88
+
89
+ `get`, `set`, `delete`, and `clear` reject on failure — wrap them in try/catch when handling user-driven cancels (e.g. user cancelled biometric prompt). `hasKey` only rejects on infrastructure failure.
90
+
91
+ ## API
92
+
93
+ | Method | Returns |
94
+ |---|---|
95
+ | `SecureStorage.set(key, value, opts?)` | `Promise<void>` |
96
+ | `SecureStorage.get(key, opts?)` | `Promise<string \| null>` |
97
+ | `SecureStorage.delete(key)` | `Promise<void>` |
98
+ | `SecureStorage.clear()` | `Promise<void>` — only this module's keys |
99
+ | `SecureStorage.hasKey(key)` | `Promise<boolean>` — no prompt, no decrypt |
100
+ | `SecureStorage.isAvailable()` | `boolean` |
101
+
102
+ `set` options:
103
+ - `requireBiometric?: boolean` — gate this key with the OS biometric prompt.
104
+
105
+ `get` options:
106
+ - `biometricPrompt?: { reason: string; title?: string }` — strings shown on the OS prompt for biometric-gated keys. If omitted (or `reason` is empty), a generic `"Authenticate to read secure data"` default is used so the prompt still appears with a non-blank subtitle. On iOS the `reason` is passed via `kSecUseOperationPrompt`; on Android it becomes the `BiometricPrompt` subtitle.
107
+
108
+ ## Recipes
109
+
110
+ ### Access token + refresh token
111
+
112
+ The common shape: a long-lived **refresh** token (no biometric gate, used at app start to mint a new access token) plus a short-lived **access** token (biometric-gated, read on every sensitive request).
113
+
114
+ ```ts
115
+ import { SecureStorage } from '@sigx/lynx-secure-storage';
116
+
117
+ async function onLoginSuccess(accessToken: string, refreshToken: string) {
118
+ // Refresh token: needed silently on cold start, so no prompt.
119
+ await SecureStorage.set('refresh_token', refreshToken);
120
+ // Access token: short-lived, prompt every time it's read.
121
+ await SecureStorage.set('access_token', accessToken, { requireBiometric: true });
122
+ }
123
+
124
+ async function getAccessTokenWithPrompt(): Promise<string | null> {
125
+ return SecureStorage.get('access_token', {
126
+ biometricPrompt: { reason: 'Unlock your account', title: 'Acme Bank' },
127
+ });
128
+ }
129
+
130
+ async function silentRefresh(): Promise<string | null> {
131
+ // No prompt — refresh_token has no requireBiometric flag.
132
+ return SecureStorage.get('refresh_token');
133
+ }
134
+
135
+ async function logout() {
136
+ await SecureStorage.delete('access_token');
137
+ await SecureStorage.delete('refresh_token');
138
+ }
139
+ ```
140
+
141
+ ### Handle key invalidation after biometric enrollment changes
142
+
143
+ Both platforms invalidate biometric-gated keys when the user adds or removes a biometric (iOS: `.biometryCurrentSet`; Android: `setInvalidatedByBiometricEnrollment(true)`). The stored blob remains on disk but `get()` rejects because the underlying key is gone. Treat this as "the user needs to sign in again":
144
+
145
+ ```ts
146
+ async function readAccessToken(): Promise<string | null> {
147
+ try {
148
+ return await SecureStorage.get('access_token', {
149
+ biometricPrompt: { reason: 'Unlock your account' },
150
+ });
151
+ } catch (err) {
152
+ const msg = (err as Error).message;
153
+ // Android surfaces the underlying KeyPermanentlyInvalidatedException
154
+ // through the native error message we wrap as "Cipher init failed
155
+ // (key may be invalidated)". Detect it by substring.
156
+ const androidInvalidated =
157
+ /may be invalidated|KeyPermanentlyInvalidated/i.test(msg);
158
+ if (androidInvalidated) {
159
+ await SecureStorage.delete('access_token');
160
+ return null; // app should send the user back to sign-in
161
+ }
162
+ throw err;
163
+ }
164
+ }
165
+ ```
166
+
167
+ **iOS caveat.** On iOS the Keychain returns `errSecAuthFailed` for both genuine biometric mismatch and "the biometric set changed since this item was stored" — the JS surface currently normalises both to `authenticationFailed`, so they can't be told apart from JS alone. Recommended approach: if `get()` for a biometric-gated key rejects with `authenticationFailed` and the user just retried, treat it the same as the Android invalidation path (delete + re-auth) rather than looping. A future module version will expose a richer `errorCode` field so the two cases can be distinguished cleanly.
168
+
169
+ The same delete-then-re-auth pattern applies when the user disables biometrics entirely between launches.
170
+
171
+ ## Threat model
172
+
173
+ **Protects against:**
174
+ - Other apps reading the credential. Both Keychain (per-app entitlement) and Keystore (per-app alias namespace) enforce this at the OS level.
175
+ - Casual device theft if `requireBiometric: true` is used — the encrypted blob is on disk but the key requires user presence to unwrap.
176
+ - Backup exfiltration on iOS — all items use `…ThisDeviceOnly` accessibility, so they don't appear in iCloud or encrypted iTunes backups.
177
+ - Filesystem inspection (e.g. `adb pull`, jailbroken backup) on values that aren't biometric-gated, because `EncryptedSharedPreferences` and the Keychain blob are encrypted at rest with a hardware-backed key.
178
+
179
+ **Does NOT protect against:**
180
+ - A jailbroken / rooted device with a determined attacker — Keychain and Keystore can be dumped offline. `requireBiometric: true` raises the bar (the attacker also needs to bypass biometric auth or extract the key from the secure element), but does not eliminate the risk.
181
+ - Memory inspection while the app is running. Once `get` returns, the decrypted string lives in the JS heap.
182
+ - Screen scraping, accessibility tree leakage, or screen recording. If you display the secret in a `<TextInput>`, treat it as already-public.
183
+ - App-level data binding (state-management, dev-tools time-travel, error reporting). If you put the decrypted secret in a Redux store and ship Sentry breadcrumbs, you've leaked it.
184
+ - An attacker who knows the device PIN if they can also defeat the biometric (e.g. with a registered fingerprint). The OS treats biometric + passcode equivalently in `LAPolicy.deviceOwnerAuthentication` / Android `DEVICE_CREDENTIAL`.
185
+ - Android Auto Backup leaking non-biometric values to Google Drive unless you add an `<exclude>` rule (see Install). Biometric-gated values are bound to a Keystore alias that can't be restored to a different device.
186
+
187
+ For "Strong" guarantees we always request the strongest available biometric class (iOS `.biometryCurrentSet`; Android `BIOMETRIC_STRONG`). Weaker face unlock sensors that don't meet `BIOMETRIC_STRONG` are reported as unavailable rather than silently downgraded.
188
+
189
+ ## Reference
190
+
191
+ The showcase app's "Auth demo" screen (`examples/showcase/src/screens/AuthDemo.tsx`) demonstrates the full sign-in → encrypted store → biometric unlock → reveal flow.
@@ -0,0 +1,22 @@
1
+ package com.sigx.securestorage
2
+
3
+ import androidx.fragment.app.FragmentActivity
4
+ import java.lang.ref.WeakReference
5
+
6
+ internal object SecureStorageActivityHolder {
7
+
8
+ private var ref: WeakReference<FragmentActivity>? = null
9
+
10
+ fun setActivity(activity: FragmentActivity) {
11
+ ref = WeakReference(activity)
12
+ }
13
+
14
+ fun clearIf(activity: Any) {
15
+ val current = ref?.get() ?: return
16
+ if (current === activity) {
17
+ ref = null
18
+ }
19
+ }
20
+
21
+ fun current(): FragmentActivity? = ref?.get()
22
+ }
@@ -0,0 +1,35 @@
1
+ package com.sigx.securestorage
2
+
3
+ import android.app.Activity
4
+ import android.os.Bundle
5
+ import androidx.fragment.app.FragmentActivity
6
+
7
+ /**
8
+ * Activity-lifecycle hook for `@sigx/lynx-secure-storage`.
9
+ *
10
+ * Discovered by the auto-linker via `signalx-module.json`'s
11
+ * `android.activityHook`. We need a [FragmentActivity] to drive
12
+ * `BiometricPrompt` when reading a key stored with
13
+ * `setUserAuthenticationRequired(true)`.
14
+ */
15
+ object SecureStorageActivityHook {
16
+
17
+ @JvmStatic
18
+ fun onCreate(activity: Activity, savedInstanceState: Bundle?) {
19
+ if (activity is FragmentActivity) {
20
+ SecureStorageActivityHolder.setActivity(activity)
21
+ }
22
+ }
23
+
24
+ @JvmStatic
25
+ fun onResume(activity: Activity) {
26
+ if (activity is FragmentActivity) {
27
+ SecureStorageActivityHolder.setActivity(activity)
28
+ }
29
+ }
30
+
31
+ @JvmStatic
32
+ fun onPause(activity: Activity) {
33
+ SecureStorageActivityHolder.clearIf(activity)
34
+ }
35
+ }
@@ -0,0 +1,348 @@
1
+ package com.sigx.securestorage
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import android.os.Build
6
+ import android.security.keystore.KeyGenParameterSpec
7
+ import android.security.keystore.KeyProperties
8
+ import android.util.Base64
9
+ import androidx.biometric.BiometricManager
10
+ import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
11
+ import androidx.biometric.BiometricPrompt
12
+ import androidx.core.content.ContextCompat
13
+ import androidx.security.crypto.EncryptedSharedPreferences
14
+ import androidx.security.crypto.MasterKey
15
+ import com.lynx.jsbridge.LynxMethod
16
+ import com.lynx.jsbridge.LynxModule
17
+ import com.lynx.react.bridge.Callback
18
+ import com.lynx.react.bridge.JavaOnlyMap
19
+ import com.lynx.react.bridge.ReadableMap
20
+ import java.security.KeyStore
21
+ import javax.crypto.Cipher
22
+ import javax.crypto.KeyGenerator
23
+ import javax.crypto.SecretKey
24
+ import javax.crypto.spec.GCMParameterSpec
25
+
26
+ /**
27
+ * Encrypted KV storage backed by Android Keystore.
28
+ *
29
+ * JS usage: `NativeModules.SecureStorage.<method>(...)`.
30
+ *
31
+ * Two storage paths:
32
+ *
33
+ * - **Without `requireBiometric`** — values stored in
34
+ * `EncryptedSharedPreferences` (`sigx_secure_storage_v1.xml`). The master
35
+ * key is hardware-backed via Keystore; values are AES-256-GCM encrypted
36
+ * on disk.
37
+ *
38
+ * - **With `requireBiometric: true`** — a per-key Keystore alias is created
39
+ * with `setUserAuthenticationRequired(true)` + biometric strong
40
+ * authenticator. Encryption happens with a `Cipher` we initialise
41
+ * ourselves; the IV and ciphertext are base64-stored in a plain
42
+ * `SharedPreferences` (`sigx_secure_storage_biometric_v1.xml`). On
43
+ * `get`, the `Cipher` is initialised in DECRYPT mode and authorised
44
+ * through `BiometricPrompt.CryptoObject` before `doFinal`.
45
+ */
46
+ class SecureStorageModule(context: Context) : LynxModule(context) {
47
+
48
+ companion object {
49
+ private const val ANDROID_KEYSTORE = "AndroidKeyStore"
50
+ private const val PLAIN_PREFS = "sigx_secure_storage_v1"
51
+ private const val BIOMETRIC_PREFS = "sigx_secure_storage_biometric_v1"
52
+ private const val KEY_ALIAS_PREFIX = "sigx.secure-storage."
53
+ private const val GCM_TAG_BITS = 128
54
+ // Marker prefix on the stored blob so a future format change can be
55
+ // detected and migrated. v1 = `${base64Iv}:${base64Ciphertext}`.
56
+ private const val BLOB_VERSION = "v1:"
57
+ }
58
+
59
+ private val plainPrefs: SharedPreferences by lazy { buildEncryptedPrefs() }
60
+ private val biometricPrefs: SharedPreferences by lazy {
61
+ mContext.getSharedPreferences(BIOMETRIC_PREFS, Context.MODE_PRIVATE)
62
+ }
63
+
64
+ @LynxMethod
65
+ fun set(key: String?, value: String?, options: ReadableMap?, callback: Callback?) {
66
+ if (key.isNullOrEmpty()) {
67
+ callback?.invoke(errorPayload("key is required")); return
68
+ }
69
+ if (value == null) {
70
+ callback?.invoke(errorPayload("value must be a string")); return
71
+ }
72
+ val requireBiometric = options?.takeIf { it.hasKey("requireBiometric") }
73
+ ?.getBoolean("requireBiometric") ?: false
74
+
75
+ try {
76
+ // A key transitioning between plain and biometric storage must
77
+ // be removed from the old bucket so `get` doesn't read stale
78
+ // data of the wrong shape.
79
+ if (requireBiometric) {
80
+ plainPrefs.edit().remove(key).apply()
81
+ writeBiometricValue(key, value)
82
+ } else {
83
+ removeBiometricValue(key)
84
+ plainPrefs.edit().putString(key, value).apply()
85
+ }
86
+ callback?.invoke(JavaOnlyMap().apply { putBoolean("ok", true) })
87
+ } catch (e: Exception) {
88
+ callback?.invoke(errorPayload("set failed: ${e.message ?: e.javaClass.simpleName}"))
89
+ }
90
+ }
91
+
92
+ @LynxMethod
93
+ fun get(key: String?, options: ReadableMap?, callback: Callback?) {
94
+ if (key.isNullOrEmpty()) {
95
+ callback?.invoke(errorPayload("key is required")); return
96
+ }
97
+ try {
98
+ // Biometric items always win when both exist — we removed the
99
+ // plain entry on `set` so this is also the only possibility.
100
+ if (biometricPrefs.contains(key)) {
101
+ decryptBiometricValue(key, options, callback)
102
+ return
103
+ }
104
+ val value = plainPrefs.getString(key, null)
105
+ val result = JavaOnlyMap()
106
+ if (value == null) result.putNull("value") else result.putString("value", value)
107
+ callback?.invoke(result)
108
+ } catch (e: Exception) {
109
+ callback?.invoke(errorPayload("get failed: ${e.message ?: e.javaClass.simpleName}"))
110
+ }
111
+ }
112
+
113
+ @LynxMethod
114
+ fun delete(key: String?, callback: Callback?) {
115
+ if (key.isNullOrEmpty()) {
116
+ callback?.invoke(errorPayload("key is required")); return
117
+ }
118
+ try {
119
+ plainPrefs.edit().remove(key).apply()
120
+ removeBiometricValue(key)
121
+ callback?.invoke(JavaOnlyMap().apply { putBoolean("ok", true) })
122
+ } catch (e: Exception) {
123
+ callback?.invoke(errorPayload("delete failed: ${e.message ?: e.javaClass.simpleName}"))
124
+ }
125
+ }
126
+
127
+ @LynxMethod
128
+ fun clear(callback: Callback?) {
129
+ try {
130
+ plainPrefs.edit().clear().apply()
131
+ val keystore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
132
+ biometricPrefs.all.keys.toList().forEach { key ->
133
+ runCatching { keystore.deleteEntry(keyAlias(key)) }
134
+ }
135
+ biometricPrefs.edit().clear().apply()
136
+ callback?.invoke(JavaOnlyMap().apply { putBoolean("ok", true) })
137
+ } catch (e: Exception) {
138
+ callback?.invoke(errorPayload("clear failed: ${e.message ?: e.javaClass.simpleName}"))
139
+ }
140
+ }
141
+
142
+ @LynxMethod
143
+ fun hasKey(key: String?, callback: Callback?) {
144
+ if (key.isNullOrEmpty()) {
145
+ callback?.invoke(JavaOnlyMap().apply { putBoolean("exists", false) }); return
146
+ }
147
+ val exists = plainPrefs.contains(key) || biometricPrefs.contains(key)
148
+ callback?.invoke(JavaOnlyMap().apply { putBoolean("exists", exists) })
149
+ }
150
+
151
+ // MARK: - EncryptedSharedPreferences (non-biometric path)
152
+
153
+ private fun buildEncryptedPrefs(): SharedPreferences {
154
+ val masterKey = MasterKey.Builder(mContext)
155
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
156
+ .build()
157
+ return EncryptedSharedPreferences.create(
158
+ mContext,
159
+ PLAIN_PREFS,
160
+ masterKey,
161
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
162
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
163
+ )
164
+ }
165
+
166
+ // MARK: - Biometric-gated path
167
+
168
+ private fun keyAlias(key: String): String = KEY_ALIAS_PREFIX + key
169
+
170
+ private fun writeBiometricValue(key: String, value: String) {
171
+ val secretKey = getOrCreateBiometricKey(key)
172
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
173
+ init(Cipher.ENCRYPT_MODE, secretKey)
174
+ }
175
+ val ciphertext = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
176
+ val iv = cipher.iv
177
+ val blob = BLOB_VERSION +
178
+ Base64.encodeToString(iv, Base64.NO_WRAP) + ":" +
179
+ Base64.encodeToString(ciphertext, Base64.NO_WRAP)
180
+ biometricPrefs.edit().putString(key, blob).apply()
181
+ }
182
+
183
+ private fun decryptBiometricValue(key: String, options: ReadableMap?, callback: Callback?) {
184
+ val blob = biometricPrefs.getString(key, null)
185
+ if (blob == null || !blob.startsWith(BLOB_VERSION)) {
186
+ callback?.invoke(JavaOnlyMap().apply { putNull("value") }); return
187
+ }
188
+ val parts = blob.removePrefix(BLOB_VERSION).split(":")
189
+ if (parts.size != 2) {
190
+ callback?.invoke(errorPayload("corrupt biometric blob for key=$key")); return
191
+ }
192
+ val iv = Base64.decode(parts[0], Base64.NO_WRAP)
193
+ val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
194
+
195
+ val secretKey = try {
196
+ loadBiometricKey(key)
197
+ } catch (e: Exception) {
198
+ callback?.invoke(errorPayload("Keystore key missing or invalidated: ${e.message}"))
199
+ return
200
+ }
201
+ if (secretKey == null) {
202
+ callback?.invoke(errorPayload("Keystore key missing for key=$key")); return
203
+ }
204
+
205
+ val cipher: Cipher = try {
206
+ Cipher.getInstance("AES/GCM/NoPadding").apply {
207
+ init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(GCM_TAG_BITS, iv))
208
+ }
209
+ } catch (e: Exception) {
210
+ // KeyPermanentlyInvalidatedException lives in android.security.keystore;
211
+ // catching by superclass avoids a hard dep and keeps the error generic.
212
+ callback?.invoke(errorPayload("Cipher init failed (key may be invalidated): ${e.message}"))
213
+ return
214
+ }
215
+
216
+ val activity = SecureStorageActivityHolder.current()
217
+ if (activity == null) {
218
+ callback?.invoke(errorPayload("No FragmentActivity in foreground for BiometricPrompt"))
219
+ return
220
+ }
221
+
222
+ val promptOpts = options?.takeIf { it.hasKey("biometricPrompt") }?.getMap("biometricPrompt")
223
+ val reason = promptOpts?.takeIf { it.hasKey("reason") }?.getString("reason")
224
+ ?.takeIf { it.isNotEmpty() } ?: "Authenticate to read secure data"
225
+ val title = promptOpts?.takeIf { it.hasKey("title") }?.getString("title")
226
+ ?.takeIf { it.isNotEmpty() } ?: "Authenticate"
227
+
228
+ val promptInfo = BiometricPrompt.PromptInfo.Builder()
229
+ .setTitle(title)
230
+ .setSubtitle(reason)
231
+ .setAllowedAuthenticators(BIOMETRIC_STRONG)
232
+ .setNegativeButtonText("Cancel")
233
+ .build()
234
+
235
+ // Up-front canAuthenticate check — surfaces NO_HARDWARE, NONE_ENROLLED,
236
+ // SECURITY_UPDATE_REQUIRED etc. as a clean error instead of letting
237
+ // BiometricPrompt fail mid-flow. Genuine "SUCCESS but still fails" cases
238
+ // (rare hardware bugs) are caught by the try/catch around
239
+ // prompt.authenticate below.
240
+ val canAuth = BiometricManager.from(mContext).canAuthenticate(BIOMETRIC_STRONG)
241
+ if (canAuth != BiometricManager.BIOMETRIC_SUCCESS) {
242
+ callback?.invoke(errorPayload("Biometrics unavailable (canAuthenticate=$canAuth)"))
243
+ return
244
+ }
245
+
246
+ val executor = ContextCompat.getMainExecutor(mContext)
247
+ val prompt = BiometricPrompt(
248
+ activity, executor,
249
+ object : BiometricPrompt.AuthenticationCallback() {
250
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
251
+ val authedCipher = result.cryptoObject?.cipher
252
+ if (authedCipher == null) {
253
+ callback?.invoke(errorPayload("BiometricPrompt returned no Cipher"))
254
+ return
255
+ }
256
+ try {
257
+ val plaintext = authedCipher.doFinal(ciphertext)
258
+ callback?.invoke(JavaOnlyMap().apply {
259
+ putString("value", String(plaintext, Charsets.UTF_8))
260
+ })
261
+ } catch (e: Exception) {
262
+ callback?.invoke(errorPayload("Decrypt failed: ${e.message}"))
263
+ }
264
+ }
265
+
266
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
267
+ val mapped = when (errorCode) {
268
+ BiometricPrompt.ERROR_USER_CANCELED,
269
+ BiometricPrompt.ERROR_NEGATIVE_BUTTON -> "userCancel"
270
+ BiometricPrompt.ERROR_LOCKOUT,
271
+ BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> "biometryLockout"
272
+ else -> "authenticationFailed"
273
+ }
274
+ callback?.invoke(errorPayload(mapped))
275
+ }
276
+
277
+ override fun onAuthenticationFailed() {
278
+ // Single failed attempt — prompt stays up. Don't invoke.
279
+ }
280
+ },
281
+ )
282
+
283
+ try {
284
+ prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
285
+ } catch (e: Exception) {
286
+ callback?.invoke(errorPayload("BiometricPrompt.authenticate failed: ${e.message}"))
287
+ }
288
+ }
289
+
290
+ private fun removeBiometricValue(key: String) {
291
+ if (biometricPrefs.contains(key)) {
292
+ biometricPrefs.edit().remove(key).apply()
293
+ }
294
+ runCatching {
295
+ val keystore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
296
+ if (keystore.containsAlias(keyAlias(key))) {
297
+ keystore.deleteEntry(keyAlias(key))
298
+ }
299
+ }
300
+ }
301
+
302
+ private fun loadBiometricKey(key: String): SecretKey? {
303
+ val keystore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
304
+ return keystore.getKey(keyAlias(key), null) as? SecretKey
305
+ }
306
+
307
+ private fun getOrCreateBiometricKey(key: String): SecretKey {
308
+ val alias = keyAlias(key)
309
+ val existing = runCatching { loadBiometricKey(key) }.getOrNull()
310
+ if (existing != null) return existing
311
+
312
+ val keyGen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
313
+ val specBuilder = KeyGenParameterSpec.Builder(
314
+ alias,
315
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
316
+ )
317
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
318
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
319
+ .setKeySize(256)
320
+ .setUserAuthenticationRequired(true)
321
+
322
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
323
+ specBuilder.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
324
+ } else {
325
+ // Pre-API 30 only has the legacy timeout-seconds API. `-1`
326
+ // means "biometric required for every cryptographic operation"
327
+ // (positive values would let one auth cover N seconds of
328
+ // subsequent uses, which is not what we want for a credential
329
+ // store). `0` is not valid for this setter.
330
+ @Suppress("DEPRECATION")
331
+ specBuilder.setUserAuthenticationValidityDurationSeconds(-1)
332
+ }
333
+
334
+ // `setInvalidatedByBiometricEnrollment` invalidates the key if the
335
+ // user adds or removes a fingerprint/face — matches iOS's
336
+ // `.biometryCurrentSet` semantic. Callers must handle the
337
+ // "key invalidated" error and re-authenticate to re-create.
338
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
339
+ specBuilder.setInvalidatedByBiometricEnrollment(true)
340
+ }
341
+
342
+ keyGen.init(specBuilder.build())
343
+ return keyGen.generateKey()
344
+ }
345
+
346
+ private fun errorPayload(message: String): JavaOnlyMap =
347
+ JavaOnlyMap().apply { putString("error", message) }
348
+ }
@@ -0,0 +1,3 @@
1
+ export { SecureStorage } from './secure-storage.js';
2
+ export type { SecureStorageSetOptions, SecureStorageGetOptions, SecureStorageBiometricPrompt, } from './secure-storage.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,YAAY,EACR,uBAAuB,EACvB,uBAAuB,EACvB,4BAA4B,GAC/B,MAAM,qBAAqB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { SecureStorage } from './secure-storage.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC"}
@@ -0,0 +1,92 @@
1
+ export interface SecureStorageSetOptions {
2
+ /**
3
+ * When true, the OS will require biometric authentication on every
4
+ * subsequent `get` of this key.
5
+ *
6
+ * - iOS: stored with `kSecAccessControlBiometryCurrentSet`. The OS shows
7
+ * the Face ID / Touch ID prompt automatically inside the Keychain
8
+ * query — no extra JS call is needed.
9
+ * - Android: the key is generated with
10
+ * `setUserAuthenticationRequired(true)`. `get` must drive a
11
+ * `BiometricPrompt` to authorise the `Cipher`, which is why
12
+ * [SecureStorageGetOptions] exposes `biometricPrompt`.
13
+ */
14
+ requireBiometric?: boolean;
15
+ }
16
+ export interface SecureStorageBiometricPrompt {
17
+ /** iOS: localizedReason for LAContext. Android: prompt subtitle. */
18
+ reason: string;
19
+ /** Android only — prompt title. Defaults to "Authenticate". */
20
+ title?: string;
21
+ }
22
+ export interface SecureStorageGetOptions {
23
+ /**
24
+ * Required when the key was stored with `requireBiometric: true`.
25
+ * Android needs the strings to render `BiometricPrompt`; iOS only uses
26
+ * `reason` (the OS prompt title is fixed).
27
+ */
28
+ biometricPrompt?: SecureStorageBiometricPrompt;
29
+ }
30
+ /**
31
+ * Encrypted at-rest key-value storage — iOS Keychain, Android Keystore +
32
+ * EncryptedSharedPreferences.
33
+ *
34
+ * For plaintext settings use [`@sigx/lynx-storage`](../lynx-storage). Use
35
+ * this package for credentials, refresh tokens, PII, and anything else
36
+ * that must survive a casual filesystem dump.
37
+ *
38
+ * Pairs with [`@sigx/lynx-biometric`](../lynx-biometric) when you also need
39
+ * an explicit auth gate (e.g. unlock-on-launch). The `requireBiometric`
40
+ * flag here gates the *individual key* via the OS Keychain / Keystore;
41
+ * use lynx-biometric for the broader "unlock the app" flow.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * import { SecureStorage } from '@sigx/lynx-secure-storage';
46
+ *
47
+ * await SecureStorage.set('access_token', token, { requireBiometric: true });
48
+ *
49
+ * const value = await SecureStorage.get('access_token', {
50
+ * biometricPrompt: { reason: 'Unlock your account', title: 'Acme Bank' },
51
+ * });
52
+ * ```
53
+ */
54
+ export declare const SecureStorage: {
55
+ /**
56
+ * Store an encrypted string. Overwrites any existing value for `key`.
57
+ * Setting `requireBiometric: true` makes future `get` calls prompt for
58
+ * biometrics; the `set` call itself never prompts.
59
+ */
60
+ readonly set: (key: string, value: string, opts?: SecureStorageSetOptions) => Promise<void>;
61
+ /**
62
+ * Read an encrypted string. Returns `null` if the key is not present.
63
+ *
64
+ * For keys stored with `requireBiometric: true`, the OS shows a prompt
65
+ * before the value is returned. Pass `biometricPrompt.reason` to set
66
+ * the displayed string; if omitted (or empty), a generic
67
+ * "Authenticate to read secure data" default is used so the prompt
68
+ * still appears.
69
+ *
70
+ * - **iOS**: `reason` is passed as `kSecUseOperationPrompt` on the
71
+ * Keychain query.
72
+ * - **Android**: `reason` becomes the `BiometricPrompt` subtitle and
73
+ * `title` (or "Authenticate") becomes the prompt title.
74
+ */
75
+ readonly get: (key: string, opts?: SecureStorageGetOptions) => Promise<string | null>;
76
+ /** Delete a single key. No-op if the key doesn't exist. */
77
+ readonly delete: (key: string) => Promise<void>;
78
+ /**
79
+ * Delete every key owned by this module's service identifier. Does NOT
80
+ * touch other apps' Keychain items, nor other Lynx packages'
81
+ * SharedPreferences.
82
+ */
83
+ readonly clear: () => Promise<void>;
84
+ /**
85
+ * Check whether a key is present without decrypting it (no biometric
86
+ * prompt). Safe to call on every render.
87
+ */
88
+ readonly hasKey: (key: string) => Promise<boolean>;
89
+ /** Whether the native module is wired in the current build. */
90
+ readonly isAvailable: () => boolean;
91
+ };
92
+ //# sourceMappingURL=secure-storage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secure-storage.d.ts","sourceRoot":"","sources":["../src/secure-storage.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,uBAAuB;IACpC;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAED,MAAM,WAAW,4BAA4B;IACzC,oEAAoE;IACpE,MAAM,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,KAAK,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,uBAAuB;IACpC;;;;OAIG;IACH,eAAe,CAAC,EAAE,4BAA4B,CAAC;CAClD;AAiBD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,aAAa;IACtB;;;;OAIG;aACG,GAAG,QACA,MAAM,SACJ,MAAM,SACP,uBAAuB,KAC9B,OAAO,CAAC,IAAI,CAAC;IAWhB;;;;;;;;;;;;;OAaG;aACG,GAAG,QAAM,MAAM,SAAQ,uBAAuB,KAAQ,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAalF,2DAA2D;aACrD,MAAM,QAAM,MAAM,KAAG,OAAO,CAAC,IAAI,CAAC;IAQxC;;;;OAIG;aACG,KAAK,QAAI,OAAO,CAAC,IAAI,CAAC;IAK5B;;;OAGG;aACG,MAAM,QAAM,MAAM,KAAG,OAAO,CAAC,OAAO,CAAC;IAa3C,+DAA+D;aAC/D,WAAW,QAAI,OAAO;CAGhB,CAAC"}
@@ -0,0 +1,108 @@
1
+ import { callAsync, isModuleAvailable } from '@sigx/lynx-core';
2
+ const MODULE = 'SecureStorage';
3
+ function unwrap(result, action) {
4
+ if (!result || result.error) {
5
+ throw new Error(`[@sigx/lynx-secure-storage] ${action} failed: ${result?.error ?? 'unknown error'}`);
6
+ }
7
+ }
8
+ /**
9
+ * Encrypted at-rest key-value storage — iOS Keychain, Android Keystore +
10
+ * EncryptedSharedPreferences.
11
+ *
12
+ * For plaintext settings use [`@sigx/lynx-storage`](../lynx-storage). Use
13
+ * this package for credentials, refresh tokens, PII, and anything else
14
+ * that must survive a casual filesystem dump.
15
+ *
16
+ * Pairs with [`@sigx/lynx-biometric`](../lynx-biometric) when you also need
17
+ * an explicit auth gate (e.g. unlock-on-launch). The `requireBiometric`
18
+ * flag here gates the *individual key* via the OS Keychain / Keystore;
19
+ * use lynx-biometric for the broader "unlock the app" flow.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * import { SecureStorage } from '@sigx/lynx-secure-storage';
24
+ *
25
+ * await SecureStorage.set('access_token', token, { requireBiometric: true });
26
+ *
27
+ * const value = await SecureStorage.get('access_token', {
28
+ * biometricPrompt: { reason: 'Unlock your account', title: 'Acme Bank' },
29
+ * });
30
+ * ```
31
+ */
32
+ export const SecureStorage = {
33
+ /**
34
+ * Store an encrypted string. Overwrites any existing value for `key`.
35
+ * Setting `requireBiometric: true` makes future `get` calls prompt for
36
+ * biometrics; the `set` call itself never prompts.
37
+ */
38
+ async set(key, value, opts = {}) {
39
+ if (typeof key !== 'string' || key.length === 0) {
40
+ throw new Error('[@sigx/lynx-secure-storage] key must be a non-empty string');
41
+ }
42
+ if (typeof value !== 'string') {
43
+ throw new Error('[@sigx/lynx-secure-storage] value must be a string');
44
+ }
45
+ const result = await callAsync(MODULE, 'set', key, value, opts);
46
+ unwrap(result, `set(${key})`);
47
+ },
48
+ /**
49
+ * Read an encrypted string. Returns `null` if the key is not present.
50
+ *
51
+ * For keys stored with `requireBiometric: true`, the OS shows a prompt
52
+ * before the value is returned. Pass `biometricPrompt.reason` to set
53
+ * the displayed string; if omitted (or empty), a generic
54
+ * "Authenticate to read secure data" default is used so the prompt
55
+ * still appears.
56
+ *
57
+ * - **iOS**: `reason` is passed as `kSecUseOperationPrompt` on the
58
+ * Keychain query.
59
+ * - **Android**: `reason` becomes the `BiometricPrompt` subtitle and
60
+ * `title` (or "Authenticate") becomes the prompt title.
61
+ */
62
+ async get(key, opts = {}) {
63
+ if (typeof key !== 'string' || key.length === 0) {
64
+ throw new Error('[@sigx/lynx-secure-storage] key must be a non-empty string');
65
+ }
66
+ const result = await callAsync(MODULE, 'get', key, opts);
67
+ if (result?.error) {
68
+ throw new Error(`[@sigx/lynx-secure-storage] get(${key}) failed: ${result.error}`);
69
+ }
70
+ return result?.value ?? null;
71
+ },
72
+ /** Delete a single key. No-op if the key doesn't exist. */
73
+ async delete(key) {
74
+ if (typeof key !== 'string' || key.length === 0) {
75
+ throw new Error('[@sigx/lynx-secure-storage] key must be a non-empty string');
76
+ }
77
+ const result = await callAsync(MODULE, 'delete', key);
78
+ unwrap(result, `delete(${key})`);
79
+ },
80
+ /**
81
+ * Delete every key owned by this module's service identifier. Does NOT
82
+ * touch other apps' Keychain items, nor other Lynx packages'
83
+ * SharedPreferences.
84
+ */
85
+ async clear() {
86
+ const result = await callAsync(MODULE, 'clear');
87
+ unwrap(result, 'clear');
88
+ },
89
+ /**
90
+ * Check whether a key is present without decrypting it (no biometric
91
+ * prompt). Safe to call on every render.
92
+ */
93
+ async hasKey(key) {
94
+ if (typeof key !== 'string' || key.length === 0) {
95
+ return false;
96
+ }
97
+ const result = await callAsync(MODULE, 'hasKey', key);
98
+ if (result?.error) {
99
+ throw new Error(`[@sigx/lynx-secure-storage] hasKey(${key}) failed: ${result.error}`);
100
+ }
101
+ return result?.exists === true;
102
+ },
103
+ /** Whether the native module is wired in the current build. */
104
+ isAvailable() {
105
+ return isModuleAvailable(MODULE);
106
+ },
107
+ };
108
+ //# sourceMappingURL=secure-storage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secure-storage.js","sourceRoot":"","sources":["../src/secure-storage.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,197 @@
1
+ import Foundation
2
+ import Lynx
3
+ import Security
4
+
5
+ /// Encrypted KV storage backed by the iOS Keychain.
6
+ ///
7
+ /// JS usage: `NativeModules.SecureStorage.<method>(...)`.
8
+ ///
9
+ /// All items are stored as `kSecClassGenericPassword` with a per-bundle
10
+ /// service identifier. Items requested with `requireBiometric: true` are
11
+ /// stored with a `SecAccessControl` of `.biometryCurrentSet`, which causes
12
+ /// `SecItemCopyMatching` to show the OS biometric prompt automatically on
13
+ /// read. Non-biometric items use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`
14
+ /// so they never appear in iCloud or encrypted iTunes backups.
15
+ class SecureStorageModule: NSObject, LynxModule {
16
+
17
+ @objc static var name: String { "SecureStorage" }
18
+
19
+ @objc static var methodLookup: [String: String] {
20
+ [
21
+ "set": NSStringFromSelector(#selector(set(_:value:options:callback:))),
22
+ "get": NSStringFromSelector(#selector(get(_:options:callback:))),
23
+ "delete": NSStringFromSelector(#selector(delete(_:callback:))),
24
+ "clear": NSStringFromSelector(#selector(clear(_:))),
25
+ "hasKey": NSStringFromSelector(#selector(hasKey(_:callback:))),
26
+ ]
27
+ }
28
+
29
+ required override init() { super.init() }
30
+ required init(param: Any) { super.init() }
31
+
32
+ private var service: String {
33
+ // Namespacing the service id with `.sigx.secure-storage` keeps our
34
+ // items separate from anything else the host app stores in the
35
+ // Keychain (other libraries, host SDKs).
36
+ let bundleId = Bundle.main.bundleIdentifier ?? "com.sigx.app"
37
+ return "\(bundleId).sigx.secure-storage"
38
+ }
39
+
40
+ @objc func set(
41
+ _ key: String?,
42
+ value: String?,
43
+ options: [String: Any]?,
44
+ callback: LynxCallbackBlock?,
45
+ ) {
46
+ guard let key = key, !key.isEmpty else {
47
+ callback?(["error": "key is required"])
48
+ return
49
+ }
50
+ guard let value = value, let data = value.data(using: .utf8) else {
51
+ callback?(["error": "value must be a string"])
52
+ return
53
+ }
54
+ let requireBiometric = (options?["requireBiometric"] as? Bool) ?? false
55
+
56
+ // Delete any prior entry under this key — `SecItemUpdate` can't
57
+ // change `kSecAttrAccessControl`, so we always wipe + add.
58
+ let baseQuery: [String: Any] = [
59
+ kSecClass as String: kSecClassGenericPassword,
60
+ kSecAttrService as String: service,
61
+ kSecAttrAccount as String: key,
62
+ ]
63
+ SecItemDelete(baseQuery as CFDictionary)
64
+
65
+ var addQuery: [String: Any] = baseQuery
66
+ addQuery[kSecValueData as String] = data
67
+
68
+ if requireBiometric {
69
+ var acError: Unmanaged<CFError>?
70
+ guard let access = SecAccessControlCreateWithFlags(
71
+ kCFAllocatorDefault,
72
+ kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
73
+ .biometryCurrentSet,
74
+ &acError,
75
+ ) else {
76
+ let err = acError?.takeRetainedValue() as Error?
77
+ callback?([
78
+ "error": "Failed to create SecAccessControl: \(err?.localizedDescription ?? "unknown")"
79
+ ])
80
+ return
81
+ }
82
+ addQuery[kSecAttrAccessControl as String] = access
83
+ } else {
84
+ addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
85
+ }
86
+
87
+ let status = SecItemAdd(addQuery as CFDictionary, nil)
88
+ if status == errSecSuccess {
89
+ callback?(["ok": true])
90
+ } else {
91
+ callback?(["error": "SecItemAdd failed: \(status)"])
92
+ }
93
+ }
94
+
95
+ @objc func get(
96
+ _ key: String?,
97
+ options: [String: Any]?,
98
+ callback: LynxCallbackBlock?,
99
+ ) {
100
+ guard let key = key, !key.isEmpty else {
101
+ callback?(["error": "key is required"])
102
+ return
103
+ }
104
+
105
+ // The user-visible prompt string for a Keychain item with a
106
+ // biometric ACL goes on the query via `kSecUseOperationPrompt`,
107
+ // NOT on an LAContext (LAContext's `localizedReason` only feeds
108
+ // `evaluatePolicy`, not Keychain UI). For non-biometric items the
109
+ // prompt key is ignored.
110
+ let reason = (options?["biometricPrompt"] as? [String: Any])?["reason"] as? String
111
+ let prompt = (reason?.isEmpty == false ? reason : "Authenticate to read secure data")
112
+ ?? "Authenticate to read secure data"
113
+
114
+ let query: [String: Any] = [
115
+ kSecClass as String: kSecClassGenericPassword,
116
+ kSecAttrService as String: service,
117
+ kSecAttrAccount as String: key,
118
+ kSecReturnData as String: true,
119
+ kSecMatchLimit as String: kSecMatchLimitOne,
120
+ kSecUseOperationPrompt as String: prompt,
121
+ ]
122
+
123
+ var result: AnyObject?
124
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
125
+
126
+ switch status {
127
+ case errSecSuccess:
128
+ if let data = result as? Data, let value = String(data: data, encoding: .utf8) {
129
+ callback?(["value": value])
130
+ } else {
131
+ callback?(["value": NSNull()])
132
+ }
133
+ case errSecItemNotFound:
134
+ callback?(["value": NSNull()])
135
+ case errSecUserCanceled, -128:
136
+ callback?(["error": "userCancel"])
137
+ case errSecAuthFailed:
138
+ callback?(["error": "authenticationFailed"])
139
+ default:
140
+ callback?(["error": "SecItemCopyMatching failed: \(status)"])
141
+ }
142
+ }
143
+
144
+ @objc func delete(_ key: String?, callback: LynxCallbackBlock?) {
145
+ guard let key = key, !key.isEmpty else {
146
+ callback?(["error": "key is required"])
147
+ return
148
+ }
149
+ let query: [String: Any] = [
150
+ kSecClass as String: kSecClassGenericPassword,
151
+ kSecAttrService as String: service,
152
+ kSecAttrAccount as String: key,
153
+ ]
154
+ let status = SecItemDelete(query as CFDictionary)
155
+ if status == errSecSuccess || status == errSecItemNotFound {
156
+ callback?(["ok": true])
157
+ } else {
158
+ callback?(["error": "SecItemDelete failed: \(status)"])
159
+ }
160
+ }
161
+
162
+ @objc func clear(_ callback: LynxCallbackBlock?) {
163
+ let query: [String: Any] = [
164
+ kSecClass as String: kSecClassGenericPassword,
165
+ kSecAttrService as String: service,
166
+ ]
167
+ let status = SecItemDelete(query as CFDictionary)
168
+ if status == errSecSuccess || status == errSecItemNotFound {
169
+ callback?(["ok": true])
170
+ } else {
171
+ callback?(["error": "SecItemDelete failed: \(status)"])
172
+ }
173
+ }
174
+
175
+ @objc func hasKey(_ key: String?, callback: LynxCallbackBlock?) {
176
+ guard let key = key, !key.isEmpty else {
177
+ callback?(["exists": false])
178
+ return
179
+ }
180
+ // `kSecUseAuthenticationUIFail` makes the query return without
181
+ // prompting when an ACL would normally require auth — we just want
182
+ // to know if the item exists, not to read it.
183
+ let query: [String: Any] = [
184
+ kSecClass as String: kSecClassGenericPassword,
185
+ kSecAttrService as String: service,
186
+ kSecAttrAccount as String: key,
187
+ kSecReturnData as String: false,
188
+ kSecMatchLimit as String: kSecMatchLimitOne,
189
+ kSecUseAuthenticationUI as String: kSecUseAuthenticationUIFail,
190
+ ]
191
+ let status = SecItemCopyMatching(query as CFDictionary, nil)
192
+ // errSecInteractionNotAllowed (-25308) means the item exists but
193
+ // needs UI we suppressed — count it as "exists".
194
+ let exists = status == errSecSuccess || status == -25308
195
+ callback?(["exists": exists])
196
+ }
197
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@sigx/lynx-secure-storage",
3
+ "version": "0.4.1",
4
+ "description": "Encrypted at-rest key-value storage for sigx-lynx (iOS Keychain, Android Keystore + EncryptedSharedPreferences)",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ },
13
+ "./signalx-module.json": "./signalx-module.json"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "ios",
18
+ "android",
19
+ "signalx-module.json"
20
+ ],
21
+ "dependencies": {
22
+ "@sigx/lynx-core": "^0.4.1"
23
+ },
24
+ "devDependencies": {
25
+ "@typescript/native-preview": "7.0.0-dev.20260521.1",
26
+ "typescript": "^6.0.3",
27
+ "vitest": "^4.1.7"
28
+ },
29
+ "author": "Andreas Ekdahl",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/signalxjs/lynx.git",
34
+ "directory": "packages/lynx-secure-storage"
35
+ },
36
+ "homepage": "https://github.com/signalxjs/lynx/tree/main/packages/lynx-secure-storage",
37
+ "bugs": {
38
+ "url": "https://github.com/signalxjs/lynx/issues"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "keywords": [
44
+ "signalx",
45
+ "sigx",
46
+ "lynx",
47
+ "mobile",
48
+ "ios",
49
+ "android",
50
+ "secure-storage",
51
+ "keychain",
52
+ "keystore",
53
+ "encrypted-shared-preferences"
54
+ ],
55
+ "scripts": {
56
+ "build": "node ../../scripts/clean.mjs dist && tsgo",
57
+ "dev": "tsgo --watch",
58
+ "test": "vitest run",
59
+ "clean": "node ../../scripts/clean.mjs dist .turbo"
60
+ }
61
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "SecureStorage",
3
+ "package": "@sigx/lynx-secure-storage",
4
+ "description": "Encrypted key-value storage (iOS Keychain, Android Keystore + EncryptedSharedPreferences)",
5
+ "platforms": ["android", "ios"],
6
+ "ios": {
7
+ "moduleClass": "SecureStorageModule",
8
+ "sourceDir": "ios",
9
+ "methods": ["set", "get", "delete", "clear", "hasKey"]
10
+ },
11
+ "android": {
12
+ "moduleClass": "com.sigx.securestorage.SecureStorageModule",
13
+ "sourceDir": "android",
14
+ "activityHook": {
15
+ "class": "com.sigx.securestorage.SecureStorageActivityHook",
16
+ "methods": ["onCreate", "onResume", "onPause"]
17
+ },
18
+ "dependencies": [
19
+ "androidx.security:security-crypto:1.1.0-alpha06",
20
+ "androidx.biometric:biometric:1.2.0-alpha05"
21
+ ],
22
+ "permissions": ["android.permission.USE_BIOMETRIC"]
23
+ }
24
+ }