diff --git a/README.md b/README.md index 14d7104..b594d43 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,11 @@ This is a plugin specific list of error codes that can be thrown on verifyIdenti * [`deleteCredentials(...)`](#deletecredentials) * [`getSecureCredentials(...)`](#getsecurecredentials) * [`isCredentialsSaved(...)`](#iscredentialssaved) +* [`setData(...)`](#setdata) +* [`getData(...)`](#getdata) +* [`getSecureData(...)`](#getsecuredata) +* [`deleteData(...)`](#deletedata) +* [`isDataSaved(...)`](#isdatasaved) * [`getPluginVersion()`](#getpluginversion) * [Interfaces](#interfaces) * [Type Aliases](#type-aliases) @@ -462,6 +467,104 @@ Checks if credentials are already saved for a given server. -------------------- +### setData(...) + +```typescript +setData(options: SetDataOptions) => Promise +``` + +Stores an arbitrary string value under the given key. +Values are encrypted at rest using the platform secure storage backend +(Android Keystore + SharedPreferences, iOS Keychain). + +For biometric-protected storage, set `accessControl` and retrieve the value +with `getSecureData()`. Credential helpers remain available for username/password flows. + +| Param | Type | +| ------------- | --------------------------------------------------------- | +| **`options`** | SetDataOptions | + +**Since:** 8.6.0 + +-------------------- + + +### getData(...) + +```typescript +getData(options: GetDataOptions) => Promise +``` + +Gets a previously stored value for the given key. +Only returns values stored without biometric `accessControl`. + +| Param | Type | +| ------------- | --------------------------------------------------------- | +| **`options`** | GetDataOptions | + +**Returns:** Promise<StoredData> + +**Since:** 8.6.0 + +-------------------- + + +### getSecureData(...) + +```typescript +getSecureData(options: GetSecureDataOptions) => Promise +``` + +Gets a biometric-protected value for the given key. +The value must have been stored with `accessControl` set to BIOMETRY_CURRENT_SET or BIOMETRY_ANY. + +| Param | Type | +| ------------- | --------------------------------------------------------------------- | +| **`options`** | GetSecureDataOptions | + +**Returns:** Promise<StoredData> + +**Since:** 8.6.0 + +-------------------- + + +### deleteData(...) + +```typescript +deleteData(options: DeleteDataOptions) => Promise +``` + +Deletes the stored value for the given key (protected and unprotected). + +| Param | Type | +| ------------- | --------------------------------------------------------------- | +| **`options`** | DeleteDataOptions | + +**Since:** 8.6.0 + +-------------------- + + +### isDataSaved(...) + +```typescript +isDataSaved(options: IsDataSavedOptions) => Promise +``` + +Checks whether a value is already saved for the given key. + +| Param | Type | +| ------------- | ----------------------------------------------------------------- | +| **`options`** | IsDataSavedOptions | + +**Returns:** Promise<IsDataSavedResult> + +**Since:** 8.6.0 + +-------------------- + + ### getPluginVersion() ```typescript @@ -584,6 +687,65 @@ Result from isAvailable() method indicating biometric authentication availabilit | **`server`** | string | +#### SetDataOptions + +| Prop | Type | Description | Default | Since | +| -------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ----- | +| **`key`** | string | Unique identifier for the stored value. Use a stable app-specific namespace (e.g. `pin`, `session.token`). | | | +| **`value`** | string | Arbitrary string payload. Serialize objects with `JSON.stringify()` before storing. Platform limits apply: Android Keystore-backed encryption works best with payloads under ~8 KB; iOS Keychain practical limits are higher but very large values are discouraged. | | | +| **`accessControl`** | AccessControl | Access control level for the stored value. When set to BIOMETRY_CURRENT_SET or BIOMETRY_ANY, the value is hardware-protected and requires biometric authentication to access via `getSecureData()`. | AccessControl.NONE | 8.6.0 | +| **`authValidityDuration`** | number | Only for Android. Ignored on iOS and web. Only meaningful together with `accessControl` set to BIOMETRY_CURRENT_SET or BIOMETRY_ANY. | 0 | 8.6.0 | +| **`title`** | string | Title for the biometric prompt shown while protecting data. Only for Android. | "Protect Data" | 8.6.0 | +| **`negativeButtonText`** | string | Text for the negative/cancel button in the biometric prompt. Only for Android. | "Cancel" | 8.6.0 | + + +#### StoredData + +| Prop | Type | +| ----------- | ------------------- | +| **`value`** | string | + + +#### GetDataOptions + +| Prop | Type | +| --------- | ------------------- | +| **`key`** | string | + + +#### GetSecureDataOptions + +| Prop | Type | Description | +| ------------------------ | ------------------- | ---------------------------------------------------------------------------------------------------------- | +| **`key`** | string | | +| **`reason`** | string | Reason for requesting biometric authentication. Displayed in the biometric prompt on both iOS and Android. | +| **`title`** | string | Title for the biometric prompt. Only for Android. | +| **`subtitle`** | string | Subtitle for the biometric prompt. Only for Android. | +| **`description`** | string | Description for the biometric prompt. Only for Android. | +| **`negativeButtonText`** | string | Text for the negative/cancel button. Only for Android. | + + +#### DeleteDataOptions + +| Prop | Type | +| --------- | ------------------- | +| **`key`** | string | + + +#### IsDataSavedResult + +| Prop | Type | +| ------------- | -------------------- | +| **`isSaved`** | boolean | + + +#### IsDataSavedOptions + +| Prop | Type | +| --------- | ------------------- | +| **`key`** | string | + + ### Type Aliases diff --git a/android/src/main/java/ee/forgr/biometric/AuthActivity.java b/android/src/main/java/ee/forgr/biometric/AuthActivity.java index ac6edc1..2998080 100644 --- a/android/src/main/java/ee/forgr/biometric/AuthActivity.java +++ b/android/src/main/java/ee/forgr/biometric/AuthActivity.java @@ -49,6 +49,23 @@ public class AuthActivity extends AppCompatActivity { private int counter = 0; private int authValidityDuration; + private boolean isSecureStorageMode() { + return ( + "setSecureCredentials".equals(mode) || + "getSecureCredentials".equals(mode) || + "setSecureData".equals(mode) || + "getSecureData".equals(mode) + ); + } + + private boolean isSecureWriteMode() { + return "setSecureCredentials".equals(mode) || "setSecureData".equals(mode); + } + + private boolean isSecureReadMode() { + return "getSecureCredentials".equals(mode) || "getSecureData".equals(mode); + } + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -61,14 +78,14 @@ protected void onCreate(Bundle savedInstanceState) { maxAttempts = Math.max(1, Math.min(5, rawMaxAttempts)); String server = getIntent().getStringExtra("server"); - if ("setSecureCredentials".equals(mode)) { + if ("setSecureCredentials".equals(mode) || "setSecureData".equals(mode)) { // Not yet persisted — this call establishes the mode for the alias. authValidityDuration = Math.max(0, getIntent().getIntExtra("authValidityDuration", 0)); - } else if ("getSecureCredentials".equals(mode)) { + } else if ("getSecureCredentials".equals(mode) || "getSecureData".equals(mode)) { authValidityDuration = getStoredAuthValidityDuration(server); } - if (("setSecureCredentials".equals(mode) || "getSecureCredentials".equals(mode)) && authValidityDuration > 0) { + if (isSecureStorageMode() && authValidityDuration > 0) { // Opt-in validity-window mode: try the Keystore operation without a prompt first. // If the window already covers us, we can finish immediately with no BiometricPrompt. if (tryWithoutPrompt()) { @@ -132,16 +149,15 @@ public void onAuthenticationError(int errorCode, @NonNull CharSequence errString @Override public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) { super.onAuthenticationSucceeded(result); - boolean isValidityWindowMode = - ("setSecureCredentials".equals(mode) || "getSecureCredentials".equals(mode)) && authValidityDuration > 0; + boolean isValidityWindowMode = isSecureStorageMode() && authValidityDuration > 0; if (isValidityWindowMode) { // The prompt carries no CryptoObject in this mode (see tryWithoutPrompt) — the // successful authentication merely unlocks the Keystore key for the validity // window. Retry the plain cipher operation now that the device is authenticated. retryAfterPrompt(); - } else if ("setSecureCredentials".equals(mode)) { + } else if ("setSecureCredentials".equals(mode) || "setSecureData".equals(mode)) { handleSetSecureCredentials(result); - } else if ("getSecureCredentials".equals(mode)) { + } else if ("getSecureCredentials".equals(mode) || "getSecureData".equals(mode)) { handleGetSecureCredentials(result); } else { if (!validateCryptoObject(result)) { @@ -165,7 +181,7 @@ public void onAuthenticationFailed() { } ); - if (("setSecureCredentials".equals(mode) || "getSecureCredentials".equals(mode)) && authValidityDuration > 0) { + if (isSecureStorageMode() && authValidityDuration > 0) { // Validity-window mode: a single authentication unlocks the Keystore key for // `authValidityDuration` seconds, so the prompt is not bound to a CryptoObject. biometricPrompt.authenticate(promptInfo); @@ -173,9 +189,9 @@ public void onAuthenticationFailed() { } BiometricPrompt.CryptoObject cryptoObject; - if ("setSecureCredentials".equals(mode)) { + if ("setSecureCredentials".equals(mode) || "setSecureData".equals(mode)) { cryptoObject = createCredentialEncryptCryptoObject(); - } else if ("getSecureCredentials".equals(mode)) { + } else if ("getSecureCredentials".equals(mode) || "getSecureData".equals(mode)) { cryptoObject = createCredentialDecryptCryptoObject(); } else { cryptoObject = createCryptoObject(); @@ -463,15 +479,21 @@ private void handleGetSecureCredentials(BiometricPrompt.AuthenticationResult res private void encryptAndStoreCredentials(Cipher cipher) { try { - String username = getIntent().getStringExtra("username"); - String password = getIntent().getStringExtra("password"); String server = getIntent().getStringExtra("server"); + byte[] plaintext; + if ("setSecureData".equals(mode)) { + String value = getIntent().getStringExtra("value"); + plaintext = value.getBytes(StandardCharsets.UTF_8); + } else { + String username = getIntent().getStringExtra("username"); + String password = getIntent().getStringExtra("password"); + JSONObject json = new JSONObject(); + json.put("u", username); + json.put("p", password); + plaintext = json.toString().getBytes(StandardCharsets.UTF_8); + } - JSONObject json = new JSONObject(); - json.put("u", username); - json.put("p", password); - - byte[] encrypted = cipher.doFinal(json.toString().getBytes(StandardCharsets.UTF_8)); + byte[] encrypted = cipher.doFinal(plaintext); byte[] iv = cipher.getIV(); byte[] combined = new byte[iv.length + encrypted.length]; @@ -506,13 +528,16 @@ private void decryptAndReturnCredentials(Cipher cipher) { System.arraycopy(combined, CREDENTIAL_GCM_IV_LENGTH, ciphertext, 0, ciphertext.length); byte[] decrypted = cipher.doFinal(ciphertext); - String jsonStr = new String(decrypted, StandardCharsets.UTF_8); - JSONObject json = new JSONObject(jsonStr); - Intent intent = new Intent(); intent.putExtra("result", "success"); - intent.putExtra("username", json.getString("u")); - intent.putExtra("password", json.getString("p")); + if ("getSecureData".equals(mode)) { + intent.putExtra("value", new String(decrypted, StandardCharsets.UTF_8)); + } else { + String jsonStr = new String(decrypted, StandardCharsets.UTF_8); + JSONObject json = new JSONObject(jsonStr); + intent.putExtra("username", json.getString("u")); + intent.putExtra("password", json.getString("p")); + } setResult(RESULT_OK, intent); finish(); } catch (Exception e) { @@ -530,7 +555,7 @@ private void decryptAndReturnCredentials(Cipher cipher) { */ private boolean tryWithoutPrompt() { try { - if ("setSecureCredentials".equals(mode)) { + if (isSecureWriteMode()) { String server = getIntent().getStringExtra("server"); int accessControl = getIntent().getIntExtra("accessControl", 2); Cipher cipher = createCredentialCipherForEncrypt(server, accessControl, authValidityDuration); @@ -562,7 +587,7 @@ private boolean tryWithoutPrompt() { */ private void retryAfterPrompt() { try { - if ("setSecureCredentials".equals(mode)) { + if (isSecureWriteMode()) { String server = getIntent().getStringExtra("server"); int accessControl = getIntent().getIntExtra("accessControl", 2); Cipher cipher = createCredentialCipherForEncrypt(server, accessControl, authValidityDuration); diff --git a/android/src/main/java/ee/forgr/biometric/NativeBiometric.java b/android/src/main/java/ee/forgr/biometric/NativeBiometric.java index d22f81d..f71376d 100644 --- a/android/src/main/java/ee/forgr/biometric/NativeBiometric.java +++ b/android/src/main/java/ee/forgr/biometric/NativeBiometric.java @@ -78,6 +78,8 @@ public class NativeBiometric extends Plugin { private static final int GCM_IV_LENGTH = 12; private static final String ENCRYPTED_KEY = "NativeBiometricKey"; private static final String NATIVE_BIOMETRIC_SHARED_PREFERENCES = "NativeBiometricSharedPreferences"; + private static final String DATA_KEY_PREFIX = "data_"; + private static final String DATA_KEYSTORE_PREFIX = "NativeBiometricData_"; private SharedPreferences encryptedSharedPreferences; @@ -484,6 +486,205 @@ public void isCredentialsSaved(final PluginCall call) { } } + private String dataStorageKey(String key) { + return DATA_KEY_PREFIX + key; + } + + private String dataKeyAlias(String key) { + return DATA_KEYSTORE_PREFIX + key; + } + + @PluginMethod + public void setData(final PluginCall call) { + String key = call.getString("key", null); + String value = call.getString("value", null); + Integer accessControl = call.getInt("accessControl", 0); + Integer authValidityDuration = call.getInt("authValidityDuration", 0); + + if (key == null || value == null) { + call.reject("Missing properties"); + return; + } + + String storageKey = dataStorageKey(key); + + if (accessControl != null && accessControl > 0) { + Intent intent = new Intent(getContext(), AuthActivity.class); + intent.putExtra("mode", "setSecureData"); + intent.putExtra("server", storageKey); + intent.putExtra("value", value); + intent.putExtra("accessControl", accessControl); + intent.putExtra("authValidityDuration", authValidityDuration != null ? authValidityDuration : 0); + + String title = call.getString("title", "Protect Data"); + if (title == null || title.trim().isEmpty()) { + title = "Protect Data"; + } + intent.putExtra("title", title); + + String negativeButtonText = call.getString("negativeButtonText", "Cancel"); + if (negativeButtonText == null || negativeButtonText.trim().isEmpty()) { + negativeButtonText = "Cancel"; + } + intent.putExtra("negativeButtonText", negativeButtonText); + + startActivityForResult(call, intent, "setSecureDataResult"); + } else { + try { + SharedPreferences.Editor editor = getContext() + .getSharedPreferences(NATIVE_BIOMETRIC_SHARED_PREFERENCES, Context.MODE_PRIVATE) + .edit(); + editor.putString(storageKey, encryptString(value, dataKeyAlias(key))); + editor.apply(); + call.resolve(); + } catch (GeneralSecurityException | IOException e) { + call.reject("Failed to save data", e); + } + } + } + + @PluginMethod + public void getData(final PluginCall call) { + String key = call.getString("key", null); + if (key == null) { + call.reject("No key was provided"); + return; + } + + String storageKey = dataStorageKey(key); + SharedPreferences sharedPreferences = getContext().getSharedPreferences(NATIVE_BIOMETRIC_SHARED_PREFERENCES, Context.MODE_PRIVATE); + String encryptedValue = sharedPreferences.getString(storageKey, null); + + if (encryptedValue == null) { + call.reject("No data found"); + return; + } + + try { + JSObject jsObject = new JSObject(); + jsObject.put("value", decryptString(encryptedValue, dataKeyAlias(key))); + call.resolve(jsObject); + } catch (GeneralSecurityException | IOException e) { + call.reject("Failed to get data"); + } + } + + @PluginMethod + public void getSecureData(final PluginCall call) { + String key = call.getString("key", null); + if (key == null) { + call.reject("No key was provided"); + return; + } + + String storageKey = dataStorageKey(key); + SharedPreferences sharedPreferences = getContext().getSharedPreferences(NATIVE_BIOMETRIC_SHARED_PREFERENCES, Context.MODE_PRIVATE); + String encryptedData = sharedPreferences.getString("secure_" + storageKey, null); + if (encryptedData == null) { + call.reject("No protected data found", "21"); + return; + } + + Intent intent = new Intent(getContext(), AuthActivity.class); + intent.putExtra("mode", "getSecureData"); + intent.putExtra("server", storageKey); + intent.putExtra("title", call.getString("title", "Authenticate")); + + String subtitle = call.getString("subtitle"); + if (subtitle != null) intent.putExtra("subtitle", subtitle); + String description = call.getString("description"); + if (description != null) intent.putExtra("description", description); + String negativeText = call.getString("negativeButtonText"); + if (negativeText != null) intent.putExtra("negativeButtonText", negativeText); + + startActivityForResult(call, intent, "getSecureDataResult"); + } + + @PluginMethod + public void deleteData(final PluginCall call) { + String key = call.getString("key", null); + if (key == null) { + call.reject("No key was provided"); + return; + } + + String storageKey = dataStorageKey(key); + String keyAlias = dataKeyAlias(key); + + try { + getKeyStore().deleteEntry(keyAlias); + SharedPreferences.Editor editor = getContext() + .getSharedPreferences(NATIVE_BIOMETRIC_SHARED_PREFERENCES, Context.MODE_PRIVATE) + .edit(); + editor.remove(storageKey); + editor.remove("secure_" + storageKey); + editor.remove("secure_" + storageKey + "_validity"); + editor.apply(); + + try { + getKeyStore().deleteEntry("NativeBiometricSecure_" + storageKey); + } catch (KeyStoreException e) { + // Ignore — may not exist + } + + call.resolve(); + } catch (KeyStoreException | CertificateException | NoSuchAlgorithmException | IOException e) { + call.reject("Failed to delete data", e); + } + } + + @PluginMethod + public void isDataSaved(final PluginCall call) { + String key = call.getString("key", null); + if (key == null) { + call.reject("No key was provided"); + return; + } + + String storageKey = dataStorageKey(key); + SharedPreferences sharedPreferences = getContext().getSharedPreferences(NATIVE_BIOMETRIC_SHARED_PREFERENCES, Context.MODE_PRIVATE); + boolean hasUnprotected = sharedPreferences.getString(storageKey, null) != null; + boolean hasProtected = sharedPreferences.getString("secure_" + storageKey, null) != null; + + JSObject ret = new JSObject(); + ret.put("isSaved", hasUnprotected || hasProtected); + call.resolve(ret); + } + + @ActivityCallback + private void setSecureDataResult(PluginCall call, ActivityResult result) { + if (result.getResultCode() == Activity.RESULT_OK) { + Intent data = result.getData(); + if (data != null && "success".equals(data.getStringExtra("result"))) { + call.resolve(); + } else { + String errorCode = data != null ? data.getStringExtra("errorCode") : "0"; + String errorDetails = data != null ? data.getStringExtra("errorDetails") : "Failed to store data"; + call.reject(errorDetails, errorCode); + } + } else { + call.reject("Failed to store data"); + } + } + + @ActivityCallback + private void getSecureDataResult(PluginCall call, ActivityResult result) { + if (result.getResultCode() == Activity.RESULT_OK) { + Intent data = result.getData(); + if (data != null && "success".equals(data.getStringExtra("result"))) { + JSObject jsObject = new JSObject(); + jsObject.put("value", data.getStringExtra("value")); + call.resolve(jsObject); + } else { + String errorCode = data != null ? data.getStringExtra("errorCode") : "0"; + String errorDetails = data != null ? data.getStringExtra("errorDetails") : "Authentication failed"; + call.reject(errorDetails, errorCode); + } + } else { + call.reject("Authentication failed"); + } + } + private String encryptString(String stringToEncrypt, String KEY_ALIAS) throws GeneralSecurityException, IOException { Cipher cipher; cipher = Cipher.getInstance(TRANSFORMATION); diff --git a/ios/Sources/NativeBiometricPlugin/NativeBiometricPlugin.swift b/ios/Sources/NativeBiometricPlugin/NativeBiometricPlugin.swift index ea0dc69..268c049 100644 --- a/ios/Sources/NativeBiometricPlugin/NativeBiometricPlugin.swift +++ b/ios/Sources/NativeBiometricPlugin/NativeBiometricPlugin.swift @@ -22,6 +22,11 @@ public class NativeBiometricPlugin: CAPPlugin, CAPBridgedPlugin { CAPPluginMethod(name: "deleteCredentials", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getSecureCredentials", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "isCredentialsSaved", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setData", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getData", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getSecureData", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "deleteData", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "isDataSaved", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getPluginVersion", returnType: CAPPluginReturnPromise) ] @@ -298,6 +303,291 @@ public class NativeBiometricPlugin: CAPPlugin, CAPBridgedPlugin { } } + private let dataService = "CapgoNativeBiometricData" + private let secureDataService = "CapgoNativeBiometricSecureData" + + private func dataAccount(_ key: String) -> String { + return key + } + + @objc func setData(_ call: CAPPluginCall) { + guard let key = call.getString("key"), let value = call.getString("value") else { + call.reject("Missing properties") + return + } + + let accessControl = call.getInt("accessControl") ?? 0 + + if accessControl > 0 { + do { + try storeProtectedData(value, key, accessControl) + call.resolve() + } catch KeychainError.duplicateItem { + do { + try deleteProtectedData(key) + try storeProtectedData(value, key, accessControl) + call.resolve() + } catch { + call.reject(error.localizedDescription) + } + } catch { + call.reject(error.localizedDescription) + } + } else { + do { + try storeDataInKeychain(value, key) + call.resolve() + } catch KeychainError.duplicateItem { + do { + try updateDataInKeychain(value, key) + call.resolve() + } catch { + call.reject(error.localizedDescription) + } + } catch { + call.reject(error.localizedDescription) + } + } + } + + @objc func getData(_ call: CAPPluginCall) { + guard let key = call.getString("key") else { + call.reject("No key was provided") + return + } + + do { + let value = try getDataFromKeychain(key) + var obj = JSObject() + obj["value"] = value + call.resolve(obj) + } catch { + call.reject(error.localizedDescription) + } + } + + @objc func getSecureData(_ call: CAPPluginCall) { + guard let key = call.getString("key") else { + call.reject("No key was provided") + return + } + + let context = LAContext() + if let reason = call.getString("reason") { + context.localizedReason = reason + } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: secureDataService, + kSecAttrAccount as String: dataAccount(key), + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + kSecUseAuthenticationContext as String: context + ] + + DispatchQueue.global(qos: .userInitiated).async { + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + DispatchQueue.main.async { + if status == errSecUserCanceled { + call.reject("User canceled biometric authentication", "16") + return + } + guard status == errSecSuccess else { + if status == errSecItemNotFound { + call.reject("No protected data found for key", "21") + } else if status == errSecAuthFailed { + call.reject("Biometric authentication failed", "10") + } else { + call.reject("Failed to retrieve data: \(status)", "0") + } + return + } + + guard let existingItem = item as? [String: Any], + let valueData = existingItem[kSecValueData as String] as? Data, + let value = String(data: valueData, encoding: .utf8) + else { + call.reject("Unexpected data format") + return + } + + var obj = JSObject() + obj["value"] = value + call.resolve(obj) + } + } + } + + @objc func deleteData(_ call: CAPPluginCall) { + guard let key = call.getString("key") else { + call.reject("No key was provided") + return + } + + do { + try deleteDataFromKeychain(key) + try deleteProtectedData(key) + call.resolve() + } catch { + call.reject(error.localizedDescription) + } + } + + @objc func isDataSaved(_ call: CAPPluginCall) { + guard let key = call.getString("key") else { + call.reject("No key was provided") + return + } + + var obj = JSObject() + obj["isSaved"] = checkDataExist(key) || checkProtectedDataExist(key) + call.resolve(obj) + } + + func storeDataInKeychain(_ value: String, _ key: String) throws { + guard let valueData = value.data(using: .utf8) else { + throw KeychainError.unexpectedPasswordData + } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: dataService, + kSecAttrAccount as String: dataAccount(key), + kSecValueData as String: valueData + ] + + let status = SecItemAdd(query as CFDictionary, nil) + guard status != errSecDuplicateItem else { throw KeychainError.duplicateItem } + guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) } + } + + func updateDataInKeychain(_ value: String, _ key: String) throws { + guard let valueData = value.data(using: .utf8) else { + throw KeychainError.unexpectedPasswordData + } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: dataService, + kSecAttrAccount as String: dataAccount(key) + ] + + let attributes: [String: Any] = [kSecValueData as String: valueData] + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) } + } + + func getDataFromKeychain(_ key: String) throws -> String { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: dataService, + kSecAttrAccount as String: dataAccount(key), + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecReturnData as String: true + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status != errSecItemNotFound else { throw KeychainError.noPassword } + guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) } + + guard let existingItem = item as? [String: Any], + let valueData = existingItem[kSecValueData as String] as? Data, + let value = String(data: valueData, encoding: .utf8) + else { + throw KeychainError.unexpectedPasswordData + } + + return value + } + + func deleteDataFromKeychain(_ key: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: dataService, + kSecAttrAccount as String: dataAccount(key) + ] + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unhandledError(status: status) + } + } + + func storeProtectedData(_ value: String, _ key: String, _ accessControl: Int) throws { + guard let valueData = value.data(using: .utf8) else { + throw KeychainError.unexpectedPasswordData + } + + let flags: SecAccessControlCreateFlags = accessControl == 1 ? .biometryCurrentSet : .biometryAny + guard let access = SecAccessControlCreateWithFlags( + kCFAllocatorDefault, + kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, + flags, + nil + ) else { + throw KeychainError.unhandledError(status: errSecParam) + } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: secureDataService, + kSecAttrAccount as String: dataAccount(key), + kSecValueData as String: valueData, + kSecAttrAccessControl as String: access + ] + + let status = SecItemAdd(query as CFDictionary, nil) + guard status != errSecDuplicateItem else { throw KeychainError.duplicateItem } + guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) } + } + + func deleteProtectedData(_ key: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: secureDataService, + kSecAttrAccount as String: dataAccount(key) + ] + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unhandledError(status: status) + } + } + + func checkDataExist(_ key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: dataService, + kSecAttrAccount as String: dataAccount(key), + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + return status == errSecSuccess + } + + func checkProtectedDataExist(_ key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: secureDataService, + kSecAttrAccount as String: dataAccount(key), + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + return status == errSecSuccess + } + @objc func isCredentialsSaved(_ call: CAPPluginCall) { guard let server = call.getString("server") else { call.reject("No server name was provided") diff --git a/src/definitions.ts b/src/definitions.ts index ec10755..2366390 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -254,6 +254,102 @@ export interface IsCredentialsSavedOptions { export interface IsCredentialsSavedResult { isSaved: boolean; } +export interface SetDataOptions { + /** + * Unique identifier for the stored value. + * Use a stable app-specific namespace (e.g. `pin`, `session.token`). + */ + key: string; + /** + * Arbitrary string payload. Serialize objects with `JSON.stringify()` before storing. + * + * Platform limits apply: Android Keystore-backed encryption works best with payloads + * under ~8 KB; iOS Keychain practical limits are higher but very large values are discouraged. + */ + value: string; + /** + * Access control level for the stored value. + * When set to BIOMETRY_CURRENT_SET or BIOMETRY_ANY, the value is hardware-protected + * and requires biometric authentication to access via `getSecureData()`. + * + * @default AccessControl.NONE + * @since 8.6.0 + */ + accessControl?: AccessControl; + /** + * Only for Android. Ignored on iOS and web. + * Only meaningful together with `accessControl` set to BIOMETRY_CURRENT_SET or BIOMETRY_ANY. + * + * @default 0 + * @since 8.6.0 + */ + authValidityDuration?: number; + /** + * Title for the biometric prompt shown while protecting data. + * Only for Android. + * + * @default "Protect Data" + * @since 8.6.0 + */ + title?: string; + /** + * Text for the negative/cancel button in the biometric prompt. + * Only for Android. + * + * @default "Cancel" + * @since 8.6.0 + */ + negativeButtonText?: string; +} + +export interface GetDataOptions { + key: string; +} + +export interface GetSecureDataOptions { + key: string; + /** + * Reason for requesting biometric authentication. + * Displayed in the biometric prompt on both iOS and Android. + */ + reason?: string; + /** + * Title for the biometric prompt. + * Only for Android. + */ + title?: string; + /** + * Subtitle for the biometric prompt. + * Only for Android. + */ + subtitle?: string; + /** + * Description for the biometric prompt. + * Only for Android. + */ + description?: string; + /** + * Text for the negative/cancel button. + * Only for Android. + */ + negativeButtonText?: string; +} + +export interface StoredData { + value: string; +} + +export interface DeleteDataOptions { + key: string; +} + +export interface IsDataSavedOptions { + key: string; +} + +export interface IsDataSavedResult { + isSaved: boolean; +} /** * Biometric authentication error codes. @@ -419,6 +515,63 @@ export interface NativeBiometricPlugin { */ isCredentialsSaved(options: IsCredentialsSavedOptions): Promise; + /** + * Stores an arbitrary string value under the given key. + * Values are encrypted at rest using the platform secure storage backend + * (Android Keystore + SharedPreferences, iOS Keychain). + * + * For biometric-protected storage, set `accessControl` and retrieve the value + * with `getSecureData()`. Credential helpers remain available for username/password flows. + * + * @param {SetDataOptions} options + * @returns {Promise} + * @memberof NativeBiometricPlugin + * @since 8.6.0 + */ + setData(options: SetDataOptions): Promise; + + /** + * Gets a previously stored value for the given key. + * Only returns values stored without biometric `accessControl`. + * + * @param {GetDataOptions} options + * @returns {Promise} + * @memberof NativeBiometricPlugin + * @since 8.6.0 + */ + getData(options: GetDataOptions): Promise; + + /** + * Gets a biometric-protected value for the given key. + * The value must have been stored with `accessControl` set to BIOMETRY_CURRENT_SET or BIOMETRY_ANY. + * + * @param {GetSecureDataOptions} options + * @returns {Promise} + * @memberof NativeBiometricPlugin + * @since 8.6.0 + */ + getSecureData(options: GetSecureDataOptions): Promise; + + /** + * Deletes the stored value for the given key (protected and unprotected). + * + * @param {DeleteDataOptions} options + * @returns {Promise} + * @memberof NativeBiometricPlugin + * @since 8.6.0 + */ + deleteData(options: DeleteDataOptions): Promise; + + /** + * Checks whether a value is already saved for the given key. + * + * @param {IsDataSavedOptions} options + * @returns {Promise} + * @memberof NativeBiometricPlugin + * @since 8.6.0 + */ + isDataSaved(options: IsDataSavedOptions): Promise; + /** * Get the native Capacitor plugin version. * diff --git a/src/web.ts b/src/web.ts index d1531fd..555464b 100644 --- a/src/web.ts +++ b/src/web.ts @@ -11,6 +11,13 @@ import type { DeleteCredentialOptions, IsCredentialsSavedOptions, IsCredentialsSavedResult, + SetDataOptions, + GetDataOptions, + GetSecureDataOptions, + DeleteDataOptions, + IsDataSavedOptions, + IsDataSavedResult, + StoredData, Credentials, BiometryChangeListener, } from './definitions'; @@ -23,6 +30,7 @@ export class NativeBiometricWeb extends WebPlugin implements NativeBiometricPlug * This is NOT secure storage and should only be used for development purposes. */ private credentialStore: Map = new Map(); + private dataStore: Map = new Map(); constructor() { super(); @@ -98,6 +106,41 @@ export class NativeBiometricWeb extends WebPlugin implements NativeBiometricPlug return Promise.resolve({ isSaved: this.credentialStore.has(_options.server) }); } + setData(_options: SetDataOptions): Promise { + console.log('setData (dummy implementation)', { key: _options.key }); + this.dataStore.set(_options.key, _options.value); + return Promise.resolve(); + } + + getData(_options: GetDataOptions): Promise { + console.log('getData (dummy implementation)', { key: _options.key }); + const value = this.dataStore.get(_options.key); + if (value === undefined) { + throw new Error('No data found for the specified key'); + } + return Promise.resolve({ value }); + } + + getSecureData(_options: GetSecureDataOptions): Promise { + console.log('getSecureData (dummy implementation)', { key: _options.key }); + const value = this.dataStore.get(_options.key); + if (value === undefined) { + throw new Error('No protected data found for the specified key'); + } + return Promise.resolve({ value }); + } + + deleteData(_options: DeleteDataOptions): Promise { + console.log('deleteData (dummy implementation)', { key: _options.key }); + this.dataStore.delete(_options.key); + return Promise.resolve(); + } + + isDataSaved(_options: IsDataSavedOptions): Promise { + console.log('isDataSaved (dummy implementation)', { key: _options.key }); + return Promise.resolve({ isSaved: this.dataStore.has(_options.key) }); + } + async getPluginVersion(): Promise<{ version: string }> { return { version: 'web' }; }