Skip to content

Commit 861219b

Browse files
committed
docs: document biometric encryption and refine security model descriptions
- Add `BIOMETRIC_ENCRYPTION.md` to provide a detailed technical overview of how database passphrases are stored and protected on Android and iOS. - Include Mermaid sequence diagrams and flowcharts illustrating the biometric enrollment and sign-in flows for both platforms. - Update `feature/biometric/domain/README.md` to link to the new documentation and refine security model descriptions. - Clarify Android Keystore implementation details, noting that hardware backing depends on the device provider and that DataStore holds opaque AES-GCM ciphertext. - Refine iOS security notes to emphasize reliance on Keychain data protection and `kSecAccessControlBiometryCurrentSet` for passphrase security. - Explicitly document residual risks, such as transient plaintext exposure in memory during database decryption.
1 parent 450e1bb commit 861219b

2 files changed

Lines changed: 325 additions & 4 deletions

File tree

docs/BIOMETRIC_ENCRYPTION.md

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
# Biometric Encryption and Key Storage
2+
3+
This document describes how NoteDelight stores and uses the database passphrase when biometric sign-in is enabled on Android and iOS.
4+
5+
The biometric feature does not replace SQLCipher database encryption. It stores a recoverable copy of the existing database passphrase so the app can unlock the encrypted database after a successful biometric prompt.
6+
7+
## Summary
8+
9+
| Platform | Where the database passphrase is stored | What protects it | What app storage contains |
10+
| --- | --- | --- | --- |
11+
| Android | Encrypted by an Android Keystore AES-GCM key, then stored in Preferences DataStore | Android Keystore key with user authentication required; biometric prompt receives the `Cipher` as a `CryptoObject` | A protobuf DataStore file containing Base64 `ciphertext` and Base64 `iv` |
12+
| iOS | Stored as a Keychain generic password item | Keychain item access control: `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` and `kSecAccessControlBiometryCurrentSet` | No app-level ciphertext file for the biometric password |
13+
14+
Important Android nuance: the implementation uses `AndroidKeyStore`, but it does not currently require StrongBox with `setIsStrongBoxBacked(true)` or verify the hardware security level with `KeyInfo`. On devices with hardware-backed Keystore support, key operations may be backed by TEE or StrongBox. The code should not be described as always StrongBox-backed.
15+
16+
## Android
17+
18+
### Components
19+
20+
| Component | Code | Stored data |
21+
| --- | --- | --- |
22+
| `AndroidBiometricInteractor` | `feature/biometric/domain/src/androidMain/.../AndroidBiometricInteractor.kt` | Creates and uses the Android Keystore key and biometric-gated `Cipher` |
23+
| `BiometricCredentialsStore` | `feature/biometric/domain/src/androidMain/.../BiometricCredentialsStore.kt` | Stores Base64 `ciphertext` and Base64 `iv` in Preferences DataStore |
24+
| SQLCipher database opener | `core/data/db-sqldelight/src/androidMain/.../AndroidDatabaseHolder.kt` | Receives the decrypted passphrase and opens `notes.db` through `SafeHelperFactory.fromUser(...)` |
25+
26+
### Data at Rest
27+
28+
```mermaid
29+
flowchart LR
30+
subgraph AppStorage["App private storage"]
31+
DS["files/datastore/notedelight_biometric_prefs.preferences_pb<br/>Preferences DataStore protobuf"]
32+
DS --> CT["ciphertext = Base64(AES-GCM(passphrase))"]
33+
DS --> IV["iv = Base64(GCM nonce)"]
34+
end
35+
36+
subgraph AndroidKeystore["AndroidKeyStore"]
37+
K["notedelight_biometric_key<br/>AES key, non-exportable through app APIs"]
38+
end
39+
40+
subgraph Database["Database storage"]
41+
DB["notes.db<br/>SQLCipher-encrypted database"]
42+
end
43+
44+
K -. "unwraps only after biometric-gated key use" .-> CT
45+
CT -. "decrypts to DB passphrase" .-> DB
46+
IV -. "GCM parameter, not secret" .-> CT
47+
```
48+
49+
The DataStore file itself is not an encrypted container. It is a binary protobuf file. Opening it as text may show readable keys such as `ciphertext` and `iv` mixed with non-printable protobuf bytes.
50+
51+
Example:
52+
53+
```text
54+
ciphertext = +/SD9SOMtFleDQVD5+H9os4=
55+
iv = qqSQOxJBoPsziWjT
56+
```
57+
58+
The Base64 values can be decoded into bytes, but the decoded `ciphertext` is still AES-GCM ciphertext. The file alone is not enough to recover the database passphrase.
59+
60+
### Enrollment Flow
61+
62+
```mermaid
63+
sequenceDiagram
64+
autonumber
65+
participant User
66+
participant VM as BiometricEnrollViewModel
67+
participant Check as CheckPasswordUseCase
68+
participant Bio as AndroidBiometricInteractor
69+
participant KS as AndroidKeyStore
70+
participant Prompt as BiometricPrompt
71+
participant Store as BiometricCredentialsStore
72+
participant DS as Preferences DataStore
73+
74+
User->>VM: Enters database passphrase
75+
VM->>Check: Verify passphrase against encrypted DB
76+
Check-->>VM: Passphrase is valid
77+
VM->>Bio: encryptAndStorePassword(passphrase)
78+
Bio->>Store: clear()
79+
Bio->>KS: Get or generate AES key<br/>alias notedelight_biometric_key
80+
KS-->>Bio: SecretKey handle
81+
Bio->>Bio: Create AES/GCM/NoPadding Cipher in ENCRYPT_MODE
82+
Bio->>Prompt: authenticate(PromptInfo, CryptoObject(cipher))
83+
User->>Prompt: Strong biometric authentication
84+
Prompt-->>Bio: Authenticated CryptoObject(cipher)
85+
Bio->>Bio: cipher.doFinal(passphrase bytes)
86+
Bio->>Store: save(Base64(ciphertext), Base64(iv))
87+
Store->>DS: Write preferences protobuf
88+
```
89+
90+
The app does not write the plaintext passphrase to DataStore. It writes only the AES-GCM output and the IV.
91+
92+
### Sign-In Flow
93+
94+
```mermaid
95+
sequenceDiagram
96+
autonumber
97+
participant User
98+
participant VM as SignInViewModel
99+
participant Bio as AndroidBiometricInteractor
100+
participant Store as BiometricCredentialsStore
101+
participant DS as Preferences DataStore
102+
participant KS as AndroidKeyStore
103+
participant Prompt as BiometricPrompt
104+
participant Check as CheckPasswordUseCase
105+
participant DB as SQLCipher notes.db
106+
107+
User->>VM: Taps "Use biometric"
108+
VM->>Bio: decryptStoredPassword(...)
109+
Bio->>Store: load()
110+
Store->>DS: Read ciphertext and iv
111+
DS-->>Store: Base64 ciphertext, Base64 iv
112+
Store-->>Bio: Stored encrypted credential pair
113+
Bio->>KS: Get key alias notedelight_biometric_key
114+
KS-->>Bio: SecretKey handle
115+
Bio->>Bio: Base64-decode ciphertext and iv
116+
Bio->>Bio: Create AES/GCM/NoPadding Cipher in DECRYPT_MODE with iv
117+
Bio->>Prompt: authenticate(PromptInfo, CryptoObject(cipher))
118+
User->>Prompt: Strong biometric authentication
119+
Prompt-->>Bio: Authenticated CryptoObject(cipher)
120+
Bio->>Bio: cipher.doFinal(ciphertext)
121+
Bio-->>VM: DecryptedPasswordResult.Success(passphrase)
122+
VM->>Check: checkPasswordUseCase(passphrase)
123+
Check->>DB: Open encrypted database using passphrase
124+
DB-->>Check: Open succeeds
125+
Check-->>VM: true
126+
```
127+
128+
After `Cipher.init(DECRYPT_MODE, key, GCMParameterSpec(..., iv))`, the IV is part of the initialized cipher state. In a Kotlin coroutine debugger, local variables that are no longer live after a suspension point may appear as `null`. That does not mean the password was derived from `null`; it means the compiler-generated coroutine state machine no longer needs to retain those locals.
129+
130+
### What Is Protected by the Android Security Hardware
131+
132+
The app asks `AndroidKeyStore` to create an AES key with:
133+
134+
- alias: `notedelight_biometric_key`
135+
- purposes: encrypt and decrypt
136+
- block mode: GCM
137+
- padding: none
138+
- user authentication required
139+
- biometric enrollment invalidation on API 24+
140+
141+
The database passphrase is not stored in the secure hardware. The Keystore key is the protected object. The app receives a `SecretKey` handle and a `Cipher`, not raw key bytes. The encrypted passphrase remains in normal app storage as DataStore bytes.
142+
143+
Because the current code does not request or verify StrongBox, use precise language:
144+
145+
- Correct: "The passphrase is encrypted with an Android Keystore key and can be decrypted only by this app's Keystore entry after successful biometric-gated key use."
146+
- Too strong for the current code: "The key is always stored in a separate secure chip."
147+
148+
### Can the DataStore File Be Decrypted Manually?
149+
150+
Not from the file alone.
151+
152+
An offline copy of `files/datastore/notedelight_biometric_prefs.preferences_pb` reveals:
153+
154+
- that biometric sign-in has stored credentials;
155+
- the preference keys `ciphertext` and `iv`;
156+
- the ciphertext length;
157+
- the GCM IV.
158+
159+
It does not reveal:
160+
161+
- the database passphrase;
162+
- the Android Keystore AES key;
163+
- raw key material that could be used on another device or by another app UID.
164+
165+
The Android app decrypts it by running on the same device, under the same app UID, retrieving the Keystore entry by alias, initializing AES-GCM with the stored IV, passing that cipher through `BiometricPrompt`, and then calling `doFinal(ciphertext)` after successful authentication.
166+
167+
## iOS
168+
169+
### Components
170+
171+
| Component | Code | Stored data |
172+
| --- | --- | --- |
173+
| `IosBiometricInteractor` | `feature/biometric/domain/src/iosMain/.../IosBiometricInteractor.kt` | Stores and reads the passphrase as a Keychain generic password item |
174+
| `IosDatabaseHolder` | `core/data/db-sqldelight/src/iosMain/.../IosDatabaseHolder.kt` | Receives the passphrase as `DatabaseConfiguration.Encryption(key, rekey)` |
175+
176+
### Data at Rest
177+
178+
```mermaid
179+
flowchart LR
180+
subgraph Keychain["iOS Keychain"]
181+
Item["Generic password item<br/>service: com.softartdev.notedelight.biometric<br/>account: db_password"]
182+
Data["value data: database passphrase bytes"]
183+
AC["Access control:<br/>WhenUnlockedThisDeviceOnly<br/>BiometryCurrentSet"]
184+
Item --> Data
185+
Item --> AC
186+
end
187+
188+
subgraph Database["Database storage"]
189+
DB["notes.db<br/>SQLCipher-encrypted database"]
190+
end
191+
192+
Item -. "returned only after Keychain access control succeeds" .-> DB
193+
```
194+
195+
iOS does not use an app-managed DataStore ciphertext file for the biometric passphrase. The app stores the passphrase bytes directly as a Keychain item, and Keychain applies the platform protection and biometric access control.
196+
197+
### Enrollment Flow
198+
199+
```mermaid
200+
sequenceDiagram
201+
autonumber
202+
participant User
203+
participant VM as BiometricEnrollViewModel
204+
participant Check as CheckPasswordUseCase
205+
participant Bio as IosBiometricInteractor
206+
participant LA as LAContext
207+
participant KC as Keychain
208+
209+
User->>VM: Enters database passphrase
210+
VM->>Check: Verify passphrase against encrypted DB
211+
Check-->>VM: Passphrase is valid
212+
VM->>Bio: encryptAndStorePassword(passphrase)
213+
Bio->>KC: Delete existing generic password item
214+
Bio->>LA: evaluatePolicy(DeviceOwnerAuthenticationWithBiometrics)
215+
User->>LA: Face ID / Touch ID authentication
216+
LA-->>Bio: success
217+
Bio->>Bio: Create SecAccessControl<br/>WhenUnlockedThisDeviceOnly + BiometryCurrentSet
218+
Bio->>KC: SecItemAdd(generic password, passphrase bytes, access control)
219+
KC-->>Bio: errSecSuccess
220+
```
221+
222+
The method name `encryptAndStorePassword` is shared across platforms, but on iOS the app does not perform its own AES encryption before storing the passphrase. The encryption and access control are provided by Keychain.
223+
224+
### Sign-In Flow
225+
226+
```mermaid
227+
sequenceDiagram
228+
autonumber
229+
participant User
230+
participant VM as SignInViewModel
231+
participant Bio as IosBiometricInteractor
232+
participant LA as LAContext
233+
participant KC as Keychain
234+
participant Check as CheckPasswordUseCase
235+
participant DB as SQLCipher notes.db
236+
237+
User->>VM: Taps "Use biometric"
238+
VM->>Bio: decryptStoredPassword(...)
239+
Bio->>KC: Probe for protected item without UI
240+
KC-->>Bio: Item exists or interaction is required
241+
Bio->>LA: Configure localized reason and cancel title
242+
Bio->>KC: SecItemCopyMatching(return data, LAContext)
243+
User->>LA: Face ID / Touch ID authentication
244+
KC-->>Bio: Passphrase NSData
245+
Bio-->>VM: DecryptedPasswordResult.Success(passphrase)
246+
VM->>Check: checkPasswordUseCase(passphrase)
247+
Check->>DB: Open encrypted database using passphrase
248+
DB-->>Check: Open succeeds
249+
Check-->>VM: true
250+
```
251+
252+
### What Is Protected by Secure Enclave and Keychain
253+
254+
The Keychain item is created with:
255+
256+
- class: generic password;
257+
- service: `com.softartdev.notedelight.biometric`;
258+
- account: `db_password`;
259+
- accessibility: `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`;
260+
- access control: `kSecAccessControlBiometryCurrentSet`.
261+
262+
The app does not store a separate wrapping key or IV. Keychain stores and protects the item. Secure Enclave participates in biometric authentication and keybag access decisions, while the app receives the passphrase only after the Keychain query succeeds.
263+
264+
`BiometryCurrentSet` is important: if the current biometric enrollment changes, the protected item should no longer be readable with the old access control state.
265+
266+
## Principal Android vs iOS Difference
267+
268+
```mermaid
269+
flowchart TB
270+
subgraph Android["Android model"]
271+
A1["DB passphrase"]
272+
A2["App-managed AES-GCM encryption"]
273+
A3["AndroidKeyStore AES key<br/>biometric-gated use"]
274+
A4["DataStore protobuf<br/>ciphertext + iv"]
275+
A1 --> A2
276+
A3 --> A2
277+
A2 --> A4
278+
end
279+
280+
subgraph IOS["iOS model"]
281+
I1["DB passphrase"]
282+
I2["Keychain generic password item"]
283+
I3["Keychain access control<br/>WhenUnlockedThisDeviceOnly + BiometryCurrentSet"]
284+
I1 --> I2
285+
I3 --> I2
286+
end
287+
```
288+
289+
Android splits the design into app storage plus Keystore-protected key use:
290+
291+
- normal app storage contains encrypted credential material;
292+
- Android Keystore contains the key entry or key operation capability;
293+
- the app performs AES-GCM encryption and decryption.
294+
295+
iOS delegates storage and access control to Keychain:
296+
297+
- Keychain stores the protected secret item;
298+
- the app does not store app-level ciphertext and IV;
299+
- Keychain returns the secret only after access control succeeds.
300+
301+
## Residual Risks
302+
303+
This design protects against offline extraction of normal app files. It does not protect against every local compromise scenario.
304+
305+
Relevant residual risks:
306+
307+
- A rooted device, debugger, Frida/Xposed hook, or malicious code running inside the app process can observe the plaintext passphrase after successful biometric authentication.
308+
- The passphrase must exist transiently in app memory because SQLCipher needs it to open the database.
309+
- Android DataStore reveals metadata such as the presence of a stored biometric credential and ciphertext length.
310+
- Android hardware-backed protection depends on the device and Keystore provider unless the app explicitly requests and verifies StrongBox or hardware security level.
311+
- Biometric authentication is an unlock convenience for an existing database passphrase, not a replacement for the passphrase itself.
312+
313+
## Implementation References
314+
315+
- Android storage: `feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/BiometricCredentialsStore.kt`
316+
- Android encryption/decryption: `feature/biometric/domain/src/androidMain/kotlin/com/softartdev/notedelight/interactor/AndroidBiometricInteractor.kt`
317+
- Android database opening: `core/data/db-sqldelight/src/androidMain/kotlin/com/softartdev/notedelight/db/AndroidDatabaseHolder.kt`
318+
- iOS Keychain storage: `feature/biometric/domain/src/iosMain/kotlin/com/softartdev/notedelight/interactor/IosBiometricInteractor.kt`
319+
- iOS database opening: `core/data/db-sqldelight/src/iosMain/kotlin/com/softartdev/notedelight/db/IosDatabaseHolder.kt`

feature/biometric/domain/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Biometric authentication domain module — platform-agnostic contract plus platf
66

77
Provides the `BiometricInteractor` expect class and its platform actuals, as well as `BiometricResult` / `DecryptedPasswordResult` domain types, and the `BiometricPlatformWrapper` expect class used to pass the Android host Activity to the biometric prompt from Compose.
88

9+
For a detailed platform security model with Android and iOS biometric encryption diagrams, see [Biometric Encryption and Key Storage](../../../docs/BIOMETRIC_ENCRYPTION.md).
10+
911
## API
1012

1113
### `BiometricInteractor`
@@ -74,17 +76,17 @@ Created from a Composable using `rememberBiometricPlatformWrapper()` (in `core:u
7476

7577
**Password storage** uses two independent layers of protection:
7678

77-
1. **Android Keystore (AES-256-GCM)** — A hardware-backed symmetric key (`notedelight_biometric_key`) is generated with `setUserAuthenticationRequired(true)` and (on API 24+) `setInvalidatedByBiometricEnrollment(true)`. The key can only be used after a successful biometric authentication and never leaves the secure hardware element.
79+
1. **Android Keystore (AES-GCM)** — A symmetric key (`notedelight_biometric_key`) is generated in `AndroidKeyStore` with `setUserAuthenticationRequired(true)` and (on API 24+) `setInvalidatedByBiometricEnrollment(true)`. The key can only be used after successful biometric-gated authentication. Hardware backing depends on the device and Keystore provider; the current code does not require StrongBox or verify the hardware security level.
7880

7981
2. **DataStore Preferences** (`BiometricCredentialsStore`)— The *encrypted* output of AES-GCM (ciphertext + IV, both Base64-encoded) is stored via DataStore Preferences.
8082

81-
> **Is the DataStore encrypted?** No — DataStore writes a plain binary Protobuf file on disk and applies no application-level encryption. However, the *values* stored inside it are already opaque AES-GCM ciphertext — they cannot be decrypted without the Android Keystore key, which is hardware-bound and biometric-gated and never leaves the secure element. An attacker with raw filesystem access would obtain unintelligible bytes with no way to recover the plaintext password without also defeating the device's secure hardware.
83+
> **Is the DataStore encrypted?** No — DataStore writes a plain binary Protobuf file on disk and applies no application-level encryption. However, the *values* stored inside it are already opaque AES-GCM ciphertext — they cannot be decrypted without the Android Keystore key for this app on this device. An attacker with only raw filesystem access would obtain Base64-encoded ciphertext and IV, not the plaintext password.
8284
8385
**Enroll flow** (`encryptAndStorePassword`):
8486
1. The existing Keystore key is reused, or a new one is generated.
8587
2. A `Cipher` is initialised in `ENCRYPT_MODE` with the Keystore key.
8688
3. `BiometricPrompt` shows the system biometric UI (via `runPrompt`); the `Cipher` is passed as a `CryptoObject` so Android can attest the authentication.
87-
4. On success the `CryptoObject`'s cipher is used to encrypt the password bytes (AES-256-GCM).
89+
4. On success the `CryptoObject`'s cipher is used to encrypt the password bytes (AES-GCM).
8890
5. `BiometricCredentialsStore.save(ciphertext, iv)` persists both Base64-encoded values.
8991

9092
**Sign-in flow** (`decryptStoredPassword`):
@@ -117,7 +119,7 @@ Encapsulates all DataStore read/write operations. Exposes `hasCredentials()`, `l
117119
- On success the raw `NSData` is decoded as UTF-8 and returned as `DecryptedPasswordResult.Success`.
118120
- `errSecItemNotFound``Unavailable` (item gone or biometry enrollment changed); other statuses are mapped via `mapKeychainStatus`.
119121

120-
> **Security note**: On iOS the password is **not** encrypted at the application layer — it is stored as plaintext bytes inside a hardware-protected Keychain item. Security is provided entirely by the Secure Enclave and `kSecAccessControlBiometryCurrentSet`. The flag ensures the item is bound to the current biometric set and invalidated on any enrollment change.
122+
> **Security note**: On iOS the password is **not** encrypted at the application layer — it is stored as plaintext bytes inside a Keychain item protected by iOS Keychain data protection and `kSecAccessControlBiometryCurrentSet`. The flag ensures the item is bound to the current biometric set and invalidated on any enrollment change.
121123
122124
### Desktop/Web (`jvmMain`, `wasmJsMain`)
123125

0 commit comments

Comments
 (0)