|
| 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` |
0 commit comments