07 - Security
Concept
1. Why
Apps need two cryptographic things again and again:
- encrypting personal data before it's stored (a name or a phone number in a local database);
- unlocking a secret with biometrics (the refresh token, so a returning user doesn't type a password).
Both are easy to get subtly wrong. Hand-rolled AES with a fixed IV, a key stored next to the data, or a biometric prompt that's just a yes/no gate in front of a normal read are all common mistakes.
sinew_security provides both behind two small interfaces, FieldCipher and BiometricVault. It's built only on vetted cryptography libraries and the platform's key storage, and Sinew writes no cryptographic primitive itself. It depends on no other Sinew package, so it's usable anywhere.
2. Shape
What it protects, and with what
| Interface | Protects | Key lives in | Unlocked by |
|---|---|---|---|
FieldCipher | individual values before they're stored (database columns, cached fields) | a data key, itself encrypted by a platform master key that never leaves the device's key storage | the app being able to use its own key (no user prompt) |
BiometricVault | one small secret, typically the refresh token | a platform item or key that's usable only after a real biometric match | Face ID, Touch ID or a fingerprint, through the system prompt |
FieldCipher
interface FieldCipher {
encrypt(plain: String, context: String = ""): String // returns base64 ciphertext
decrypt(cipher: String, context: String = ""): String // throws DecryptionFailed if it can't
}
| Rule | Why |
|---|---|
| Authenticated encryption only: AES-256-GCM through a vetted library | Integrity and confidentiality together. A tampered value fails to decrypt instead of decrypting to garbage. |
| A fresh 96-bit nonce for every value, from a cryptographically secure random source (the library's, or the platform's secure random where the library takes the nonce as input), never a counter, a constant or a reused value | A repeated nonce with GCM leaks data |
| The ciphertext carries a key id, so the key can be rotated | Old values stay readable after a rotation and are re-encrypted when rewritten |
context is bound as associated data, for example "customers.displayName:<rowId>" | A ciphertext copied into another row or column fails to decrypt, so values can't be swapped between rows |
| The data key is generated on the device and stored encrypted by a master key in the platform key storage. The master key can't be exported. | Copying the app's files to another device yields nothing readable |
decrypt failure throws DecryptionFailed, a plain error. The storage guard maps it to LocalStorageCorruptionException. | The caller treats the value as absent, never crashes |
Which values to encrypt. Fields that identify a person or are personal (name, phone, email, address, ID numbers) are encrypted. Fields the app must query or sort on stay plain and must not identify a person (a status, a date, a non-identifying category). Searching encrypted fields is out of scope.
BiometricVault
interface BiometricVault {
availability(): BiometricAvailability // Available | NotEnrolled | Unavailable
store(secret: String) // after a real sign-in, when the user opts in
unlock(reason: String): BiometricUnlock // Unlocked(secret) only after a real biometric match, else Failed(reason)
disable() // deletes the protected secret
}
| Rule | Why |
|---|---|
| The secret is protected by the biometric key or access control, never by "show a prompt, then read a normal item" | A prompt in front of a normal read is a boolean gate that a modified app can skip |
| Biometrics unlock the refresh token, never a password | A stored password can be replayed anywhere. A refresh token can be revoked by the backend. |
| Opt-in only after a real sign-in, with a password fallback always available | Biometrics are a convenience, not the only way in |
| A change in enrolled biometrics (a new face or fingerprint) invalidates the secret | Someone who adds their own fingerprint to an unlocked phone can't unlock the app |
unlock fails with a typed reason: cancelled by the user, too many attempts (locked out), invalidated, unavailable | The page can offer the password fallback, or re-enrolment, as fits |
Platform building blocks
The interfaces are the same everywhere. Each platform implements them with its own vetted pieces:
| Platform | FieldCipher | Master key | BiometricVault |
|---|---|---|---|
| Android | Tink AEAD keyset | Android Keystore (StrongBox when available) | Keystore key requiring strong biometrics on every use, invalidated on new enrollment, unlocked with BiometricPrompt + CryptoObject |
| iOS | AES-256-GCM through the platform's crypto (CryptoKit / CommonCrypto), via a vetted wrapper | Keychain item, this-device-only | Keychain item with access control .biometryCurrentSet, unlocked with the system prompt |
| JVM desktop | Tink AEAD keyset | the operating system's credential store when one is available | Unavailable |
| Web | AES-GCM through Web Crypto | a non-extractable Web Crypto key in the browser's storage | Unavailable |
Web and desktop have no hardware-backed key storage comparable to a phone. Their FieldCipher protects against casually reading stored data, not against code running in the page or as the user.
What stays app policy
Which fields are personal, when biometrics are offered, how long a session lives, whether an integrity check (device attestation) is required: those are product decisions. Sinew provides the mechanisms.
3. API
| Name | Signature (pseudocode) | For |
|---|---|---|
FieldCipher | encrypt(plain, context = ""): String, decrypt(cipher, context = ""): String | field-level encryption |
FieldCipher.create | FieldCipher.create(keyAlias = "sinew.field", …platform options) | the platform implementation, creating its data key on first use |
DecryptionFailed | a plain error (not an AppException) | thrown by decrypt; the storage guard maps it |
BiometricVault | availability(), store(secret), unlock(reason): BiometricUnlock, disable() | a biometric-protected secret |
BiometricUnlock | Unlocked(secret) | Failed(reason: BiometricFailure) | the outcome of unlock |
BiometricVault.create | BiometricVault.create(keyAlias = "sinew.biometric", clearOnReinstall = true, …platform options) | the platform implementation. Like SecureStore, it deletes its item on the first use after an install, so a reinstalled app never unlocks the previous install's session. |
BiometricAvailability | Available | NotEnrolled | Unavailable | whether to offer biometrics |
BiometricFailure | Cancelled | LockedOut | Invalidated | Unavailable | why unlock failed |
unlock returns its own small sealed outcome rather than Result, so sinew_security depends on no other Sinew package on either platform. A biometric failure is a user outcome (cancelled, locked out), not an error with a code.
4. Build steps
FieldCipheron Android/JVM with Tink, test first:- a round trip;
- two encryptions of the same value differ;
- a changed byte fails with
DecryptionFailed; - a wrong
contextfails; - a value encrypted before a key rotation still decrypts.
FieldCipheron iOS and web, with the same tests on a simulator and in a browser test runner.BiometricVaulton Android and iOS, tested on devices or simulators with enrolled biometrics:- store and unlock;
- cancel;
- invalidation after enrollment changes (simulated where the platform allows);
disable.
- The
Unavailableimplementations for desktop and web, with a test thatavailability()says so and thatstorefails clearly.
Platform setup (the app's part)
- iOS:
NSFaceIDUsageDescriptionin Info.plist, or the app crashes on the first Face ID use. - Android: no extra permission for
BiometricPrompt. Exclude the key-encrypted files from backup, because restored ciphertext can't be decrypted on a new device anyway.
Done when
- Every test in step 1 passes on every target that has a
FieldCipher. - A biometric secret can't be read without a real biometric match, on Android and iOS.
- Enrolling a new biometric makes the stored secret unreadable.
- No Sinew code invents a cipher mode or a key size, and every nonce comes from a secure random source, fresh for each value.
- On iOS, a reinstalled app finds no biometric item from the previous install.
5. Edge cases
| Case | Decided behavior |
|---|---|
| The master key is lost or invalidated (restore onto a new device, OS update, a factory-reset keystore) | Every decrypt throws DecryptionFailed → LocalStorageCorruptionException. The data is treated as absent. The app's policy is to clear the encrypted data and sign the user out, never to crash. |
| A new face or fingerprint is enrolled | unlock fails with Invalidated. The app deletes the secret, asks for a password sign-in, and offers opt-in again. |
| The user cancels the biometric prompt | unlock fails with Cancelled. The page shows the password option. Nothing is logged as an error. |
| Too many failed biometric attempts | LockedOut: the page falls back to the password. |
| The app runs on a device with no biometric hardware | availability() returns Unavailable, and the app never offers it. |
| A ciphertext moved to another row | Decryption fails because its context differs. Treated as corruption. |
| A key rotation | New values use the new key. Old values still decrypt (the key id is in the ciphertext) and are re-encrypted when next written. |
| Web or desktop | FieldCipher works with a weaker protection level, stated in the platform note. BiometricVault is Unavailable. |
Implementation
1. Why
The base note settles "vetted libraries and platform crypto behind one interface". On Kotlin, that becomes a concrete choice per target, plus the key storage each target offers. This note makes those choices, and shows the biometric flows on Android and iOS.
2. Shape
Building blocks per target
| Target | AES-256-GCM | Where the data key lives | Biometrics |
|---|---|---|---|
| Android | Tink Aead (AES256_GCM) | a Tink keyset, encrypted by an Android Keystore master key (StrongBox when available), stored by AndroidKeysetManager | Keystore key requiring BIOMETRIC_STRONG, BiometricPrompt + CryptoObject |
| iOS | cryptography-kotlin, CryptoKit provider (AES.GCM) | a 256-bit key in the Keychain, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly | Keychain item with SecAccessControl .biometryCurrentSet |
| JVM desktop | Tink Aead | a Tink keyset in the user's app-data folder, file permissions owner-only | Unavailable |
| wasmJs | cryptography-kotlin, WebCrypto provider (AES-GCM) | a non-extractable CryptoKey in IndexedDB | Unavailable |
Every target produces the same ciphertext layout, so the format is documented once: base64( version(1) | keyId(4) | nonce(12) | ciphertext | tag(16) ). Tink's own output prefix already carries a key id. The iOS and web implementations write the same 5-byte header themselves, so a value's key is known before decrypting.
FieldCipher
public interface FieldCipher {
public suspend fun encrypt(plain: String, context: String = ""): String
public suspend fun decrypt(cipher: String, context: String = ""): String // throws DecryptionFailed
public companion object { public fun create(platform: PlatformContext, keyAlias: String = "sinew.field"): FieldCipher }
}
public class DecryptionFailed(cause: Throwable? = null) : IllegalStateException("Value could not be decrypted", cause)
encrypt and decrypt are suspend because key loading touches the Keystore, the Keychain or IndexedDB the first time. context is passed as associated data (UTF-8).
BiometricVault on Android
// the key: generated once in the Keystore
KeyGenParameterSpec.Builder(alias, PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
.setBlockModes(BLOCK_MODE_GCM).setEncryptionPaddings(ENCRYPTION_PADDING_NONE).setKeySize(256)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG) // every use needs a fresh biometric match
.setInvalidatedByBiometricEnrollment(true)
store(secret) encrypts with a Cipher unlocked by BiometricPrompt(CryptoObject(cipher)), then saves the IV and ciphertext in DataStore. unlock(reason) decrypts the same way. The prompt needs a FragmentActivity. BiometricVault.create registers an activity-lifecycle callback on the Application, so it always knows the current one.
| Android error | BiometricFailure |
|---|---|
ERROR_USER_CANCELED, ERROR_NEGATIVE_BUTTON, ERROR_CANCELED | Cancelled |
ERROR_LOCKOUT, ERROR_LOCKOUT_PERMANENT | LockedOut |
KeyPermanentlyInvalidatedException | Invalidated: the key and the stored secret are deleted |
ERROR_HW_UNAVAILABLE, ERROR_NO_BIOMETRICS | Unavailable |
BiometricVault on iOS
The secret is a Keychain generic-password item created with SecAccessControlCreateWithFlags(kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .biometryCurrentSet). Reading it, with an LAContext carrying the reason text, shows the system prompt. No match, no value.
| iOS result | BiometricFailure |
|---|---|
errSecUserCanceled, LAError.userCancel, LAError.appCancel | Cancelled |
LAError.biometryLockout | LockedOut |
errSecItemNotFound after biometrics changed (the item is invalidated by .biometryCurrentSet) | Invalidated |
LAError.biometryNotAvailable, biometryNotEnrolled | Unavailable / NotEnrolled from availability() |
3. API
| Name | Kotlin |
|---|---|
FieldCipher | as above |
BiometricVault | public interface BiometricVault { public suspend fun availability(): BiometricAvailability; public suspend fun store(secret: String); public suspend fun unlock(reason: String): BiometricUnlock; public suspend fun disable() }, create(platform, keyAlias = "sinew.biometric", clearOnReinstall = true) |
BiometricUnlock | public sealed interface { Unlocked(secret: String), Failed(reason: BiometricFailure) } |
BiometricAvailability, BiometricFailure | public enum class |
4. Build steps
FieldCipheron Android and JVM with Tink, then iOS (CryptoKit provider) and wasm (WebCrypto provider). The sharedcommonTestsuite runs on every target:- a round trip;
- two encryptions differ;
- a flipped byte fails;
- a wrong context fails;
- the header carries the key id.
- A cross-target fixture test: a ciphertext produced on one target with a test key decrypts on the others. That proves the shared layout.
BiometricVaulton Android (instrumented, with an emulator fingerprint) and iOS (simulator with enrolled Face ID). On iOS,clearOnReinstalluses the same marker file asSecureStore(see Storage); a test removes the marker and finds no vault item afterwards.- The
Unavailablevaults on JVM and wasm.
Platform setup (the app's part)
- Android:
androidx.biometricneeds aFragmentActivity(orAppCompatActivity) as the host. Exclude the Tink keyset preferences and the vault's DataStore file from cloud backup and device transfer indata_extraction_rules.xml. - iOS:
NSFaceIDUsageDescriptioninInfo.plist.
Done when
- The shared cipher suite passes on all four targets, including the cross-target fixture.
- A biometric secret can't be read without a real match on Android and iOS, and enrolling a new biometric makes it
Invalidated.
5. Edge cases
| Case | Decided behavior |
|---|---|
| The Keystore master key is lost (a restore to a new device) | Loading the keyset throws. create catches that, deletes the unreadable keyset and starts a fresh one; every old value then throws DecryptionFailed (absent). Verify at S4 how AndroidKeysetManager reports the failure. |
| StrongBox isn't available | The master key falls back to the regular Keystore. Tink handles the choice. |
The app's activity isn't a FragmentActivity | unlock returns Failed(Unavailable) and logs a developer warning. |
| JVM desktop key file copied to another machine | The values decrypt there: the desktop level only protects against casual reading, as the base note states. |
- cryptography-kotlin (CryptoKit provider on iOS, WebCrypto on wasm) for AES-GCM where Tink doesn't exist. This is the base note's "one multiplatform crypto library" evaluation: it covers iOS and web, but not Android Keystore integration, so Android and JVM keep Tink.
- Web keys: the data key is a non-extractable Web Crypto key persisted in IndexedDB. Verify at S4 that cryptography-kotlin can create and reuse such a key; if not,
wasmJsMaincalls Web Crypto through JS interop directly. - One documented ciphertext layout across targets, so data written on one platform's implementation is readable by another's in tests.
- A desktop key file with owner-only permissions, the weaker documented level, instead of depending on an OS-keychain library.