Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -462,6 +467,104 @@ Checks if credentials are already saved for a given server.
--------------------


### setData(...)

```typescript
setData(options: SetDataOptions) => Promise<void>
```

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`** | <code><a href="#setdataoptions">SetDataOptions</a></code> |

**Since:** 8.6.0

--------------------


### getData(...)

```typescript
getData(options: GetDataOptions) => Promise<StoredData>
```

Gets a previously stored value for the given key.
Only returns values stored without biometric `accessControl`.

| Param | Type |
| ------------- | --------------------------------------------------------- |
| **`options`** | <code><a href="#getdataoptions">GetDataOptions</a></code> |

**Returns:** <code>Promise&lt;<a href="#storeddata">StoredData</a>&gt;</code>

**Since:** 8.6.0

--------------------


### getSecureData(...)

```typescript
getSecureData(options: GetSecureDataOptions) => Promise<StoredData>
```

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`** | <code><a href="#getsecuredataoptions">GetSecureDataOptions</a></code> |

**Returns:** <code>Promise&lt;<a href="#storeddata">StoredData</a>&gt;</code>

**Since:** 8.6.0

--------------------


### deleteData(...)

```typescript
deleteData(options: DeleteDataOptions) => Promise<void>
```

Deletes the stored value for the given key (protected and unprotected).

| Param | Type |
| ------------- | --------------------------------------------------------------- |
| **`options`** | <code><a href="#deletedataoptions">DeleteDataOptions</a></code> |

**Since:** 8.6.0

--------------------


### isDataSaved(...)

```typescript
isDataSaved(options: IsDataSavedOptions) => Promise<IsDataSavedResult>
```

Checks whether a value is already saved for the given key.

| Param | Type |
| ------------- | ----------------------------------------------------------------- |
| **`options`** | <code><a href="#isdatasavedoptions">IsDataSavedOptions</a></code> |

**Returns:** <code>Promise&lt;<a href="#isdatasavedresult">IsDataSavedResult</a>&gt;</code>

**Since:** 8.6.0

--------------------


### getPluginVersion()

```typescript
Expand Down Expand Up @@ -584,6 +687,65 @@ Result from isAvailable() method indicating biometric authentication availabilit
| **`server`** | <code>string</code> |


#### SetDataOptions

| Prop | Type | Description | Default | Since |
| -------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ----- |
| **`key`** | <code>string</code> | Unique identifier for the stored value. Use a stable app-specific namespace (e.g. `pin`, `session.token`). | | |
| **`value`** | <code>string</code> | 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`** | <code><a href="#accesscontrol">AccessControl</a></code> | 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()`. | <code>AccessControl.NONE</code> | 8.6.0 |
| **`authValidityDuration`** | <code>number</code> | Only for Android. Ignored on iOS and web. Only meaningful together with `accessControl` set to BIOMETRY_CURRENT_SET or BIOMETRY_ANY. | <code>0</code> | 8.6.0 |
| **`title`** | <code>string</code> | Title for the biometric prompt shown while protecting data. Only for Android. | <code>"Protect Data"</code> | 8.6.0 |
| **`negativeButtonText`** | <code>string</code> | Text for the negative/cancel button in the biometric prompt. Only for Android. | <code>"Cancel"</code> | 8.6.0 |


#### StoredData

| Prop | Type |
| ----------- | ------------------- |
| **`value`** | <code>string</code> |


#### GetDataOptions

| Prop | Type |
| --------- | ------------------- |
| **`key`** | <code>string</code> |


#### GetSecureDataOptions

| Prop | Type | Description |
| ------------------------ | ------------------- | ---------------------------------------------------------------------------------------------------------- |
| **`key`** | <code>string</code> | |
| **`reason`** | <code>string</code> | Reason for requesting biometric authentication. Displayed in the biometric prompt on both iOS and Android. |
| **`title`** | <code>string</code> | Title for the biometric prompt. Only for Android. |
| **`subtitle`** | <code>string</code> | Subtitle for the biometric prompt. Only for Android. |
| **`description`** | <code>string</code> | Description for the biometric prompt. Only for Android. |
| **`negativeButtonText`** | <code>string</code> | Text for the negative/cancel button. Only for Android. |


#### DeleteDataOptions

| Prop | Type |
| --------- | ------------------- |
| **`key`** | <code>string</code> |


#### IsDataSavedResult

| Prop | Type |
| ------------- | -------------------- |
| **`isSaved`** | <code>boolean</code> |


#### IsDataSavedOptions

| Prop | Type |
| --------- | ------------------- |
| **`key`** | <code>string</code> |


### Type Aliases


Expand Down
73 changes: 49 additions & 24 deletions android/src/main/java/ee/forgr/biometric/AuthActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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()) {
Expand Down Expand Up @@ -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)) {
Expand All @@ -165,17 +181,17 @@ 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);
return;
}

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();
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading