Skip to main content

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​

InterfaceProtectsKey lives inUnlocked by
FieldCipherindividual 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 storagethe app being able to use its own key (no user prompt)
BiometricVaultone small secret, typically the refresh tokena platform item or key that's usable only after a real biometric matchFace 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
}
RuleWhy
Authenticated encryption only: AES-256-GCM through a vetted libraryIntegrity 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 valueA repeated nonce with GCM leaks data
The ciphertext carries a key id, so the key can be rotatedOld 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
}
RuleWhy
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 passwordA 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 availableBiometrics are a convenience, not the only way in
A change in enrolled biometrics (a new face or fingerprint) invalidates the secretSomeone 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, unavailableThe 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:

PlatformFieldCipherMaster keyBiometricVault
AndroidTink AEAD keysetAndroid Keystore (StrongBox when available)Keystore key requiring strong biometrics on every use, invalidated on new enrollment, unlocked with BiometricPrompt + CryptoObject
iOSAES-256-GCM through the platform's crypto (CryptoKit / CommonCrypto), via a vetted wrapperKeychain item, this-device-onlyKeychain item with access control .biometryCurrentSet, unlocked with the system prompt
JVM desktopTink AEAD keysetthe operating system's credential store when one is availableUnavailable
WebAES-GCM through Web Cryptoa non-extractable Web Crypto key in the browser's storageUnavailable

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​

NameSignature (pseudocode)For
FieldCipherencrypt(plain, context = ""): String, decrypt(cipher, context = ""): Stringfield-level encryption
FieldCipher.createFieldCipher.create(keyAlias = "sinew.field", …platform options)the platform implementation, creating its data key on first use
DecryptionFaileda plain error (not an AppException)thrown by decrypt; the storage guard maps it
BiometricVaultavailability(), store(secret), unlock(reason): BiometricUnlock, disable()a biometric-protected secret
BiometricUnlockUnlocked(secret) | Failed(reason: BiometricFailure)the outcome of unlock
BiometricVault.createBiometricVault.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.
BiometricAvailabilityAvailable | NotEnrolled | Unavailablewhether to offer biometrics
BiometricFailureCancelled | LockedOut | Invalidated | Unavailablewhy 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​

  1. FieldCipher on Android/JVM with Tink, test first:
    • a round trip;
    • two encryptions of the same value differ;
    • a changed byte fails with DecryptionFailed;
    • a wrong context fails;
    • a value encrypted before a key rotation still decrypts.
  2. FieldCipher on iOS and web, with the same tests on a simulator and in a browser test runner.
  3. BiometricVault on 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.
  4. The Unavailable implementations for desktop and web, with a test that availability() says so and that store fails clearly.

Platform setup (the app's part)​

  • iOS: NSFaceIDUsageDescription in 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​

CaseDecided 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 enrolledunlock fails with Invalidated. The app deletes the secret, asks for a password sign-in, and offers opt-in again.
The user cancels the biometric promptunlock fails with Cancelled. The page shows the password option. Nothing is logged as an error.
Too many failed biometric attemptsLockedOut: the page falls back to the password.
The app runs on a device with no biometric hardwareavailability() returns Unavailable, and the app never offers it.
A ciphertext moved to another rowDecryption fails because its context differs. Treated as corruption.
A key rotationNew 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 desktopFieldCipher 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​

TargetAES-256-GCMWhere the data key livesBiometrics
AndroidTink Aead (AES256_GCM)a Tink keyset, encrypted by an Android Keystore master key (StrongBox when available), stored by AndroidKeysetManagerKeystore key requiring BIOMETRIC_STRONG, BiometricPrompt + CryptoObject
iOScryptography-kotlin, CryptoKit provider (AES.GCM)a 256-bit key in the Keychain, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnlyKeychain item with SecAccessControl .biometryCurrentSet
JVM desktopTink Aeada Tink keyset in the user's app-data folder, file permissions owner-onlyUnavailable
wasmJscryptography-kotlin, WebCrypto provider (AES-GCM)a non-extractable CryptoKey in IndexedDBUnavailable

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 errorBiometricFailure
ERROR_USER_CANCELED, ERROR_NEGATIVE_BUTTON, ERROR_CANCELEDCancelled
ERROR_LOCKOUT, ERROR_LOCKOUT_PERMANENTLockedOut
KeyPermanentlyInvalidatedExceptionInvalidated: the key and the stored secret are deleted
ERROR_HW_UNAVAILABLE, ERROR_NO_BIOMETRICSUnavailable

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 resultBiometricFailure
errSecUserCanceled, LAError.userCancel, LAError.appCancelCancelled
LAError.biometryLockoutLockedOut
errSecItemNotFound after biometrics changed (the item is invalidated by .biometryCurrentSet)Invalidated
LAError.biometryNotAvailable, biometryNotEnrolledUnavailable / NotEnrolled from availability()

3. API​

NameKotlin
FieldCipheras above
BiometricVaultpublic 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)
BiometricUnlockpublic sealed interface { Unlocked(secret: String), Failed(reason: BiometricFailure) }
BiometricAvailability, BiometricFailurepublic enum class

4. Build steps​

  1. FieldCipher on Android and JVM with Tink, then iOS (CryptoKit provider) and wasm (WebCrypto provider). The shared commonTest suite runs on every target:
    • a round trip;
    • two encryptions differ;
    • a flipped byte fails;
    • a wrong context fails;
    • the header carries the key id.
  2. A cross-target fixture test: a ciphertext produced on one target with a test key decrypts on the others. That proves the shared layout.
  3. BiometricVault on Android (instrumented, with an emulator fingerprint) and iOS (simulator with enrolled Face ID). On iOS, clearOnReinstall uses the same marker file as SecureStore (see Storage); a test removes the marker and finds no vault item afterwards.
  4. The Unavailable vaults on JVM and wasm.

Platform setup (the app's part)​

  • Android: androidx.biometric needs a FragmentActivity (or AppCompatActivity) as the host. Exclude the Tink keyset preferences and the vault's DataStore file from cloud backup and device transfer in data_extraction_rules.xml.
  • iOS: NSFaceIDUsageDescription in Info.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​

CaseDecided 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 availableThe master key falls back to the regular Keystore. Tink handles the choice.
The app's activity isn't a FragmentActivityunlock returns Failed(Unavailable) and logs a developer warning.
JVM desktop key file copied to another machineThe values decrypt there: the desktop level only protects against casual reading, as the base note states.
Decided by default (revisit during implementation)
  • 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, wasmJsMain calls 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.