This area documents the work owned by M3 for NAYAN: SQLCipher-backed secure storage, hardware-backed key management, per-person embedding encryption, and admin escrow for demo recovery.
The implementation is intentionally split into two key layers:
- Device database key: one permanent device key with alias
offline_face_auth_db_v1. This key is used only to derive the SQLCipher database passphrase. - Per-person embedding KEK: one key per enrolled person with alias
face_embed_key_{personnelId}. This key wraps the 32-byte DEK that encrypts that person's face embedding.
These two layers must stay separate. The device DB key is never deleted during normal app use. Per-person keys are designed to be deleted later for right-to-erasure without affecting other personnel.
Main file:
android/app/src/main/java/com/offlinefaceauth/keystore/KeystoreManager.java
What it does:
- Creates AES-256-GCM keys in
AndroidKeyStore. - Prefers StrongBox on Android P/API 28+.
- Falls back to TEE/software-backed Android Keystore when StrongBox is not available.
- Uses
setUserAuthenticationRequired(false)so field verification can run offline without a device unlock prompt. - Uses randomized encryption with GCM and no padding.
Implemented methods:
getOrCreateAesGcmKey()returns the permanent DB passphrase derivation key.generatePersonAesGcmKey(personnelId)creates a new per-person embedding KEK and throws if the alias already exists.getPersonAesGcmKey(personnelId)loads an existing per-person KEK.deletePersonAesGcmKey(personnelId)deletes the per-person KEK.getHardwareInfo(key)reports whether a key is StrongBox/TEE backed.
Main Android bridge files:
android/app/src/main/java/com/offlinefaceauth/NativeBridge.javaandroid/app/src/main/java/com/offlinefaceauth/EmbeddingCryptoModule.javaandroid/app/src/main/java/com/offlinefaceauth/CryptoUtils.java
Exposed key methods:
generateSecureRandomBase64(byteLength)deriveDatabasePassphrase(nonceBase64)generatePersonKey(personnelId)wrapDEK(personnelId, dekHex)unwrapDEK(personnelId, wrappedDEKBase64)deletePersonKey(personnelId)
wrapDEK and unwrapDEK use AES-GCM with a fresh 12-byte IV. The returned blob
format is:
[IV: 12 bytes][ciphertext][GCM tag: 16 bytes]
The TypeScript compatibility wrapper is:
src/crypto/NativeSecureKey.ts
It resolves the active native module from NativeModules.NativeSecureKey,
NativeModules.SecureEnclaveManager, or NativeModules.NativeBridge.
Main files:
src/crypto/EmbeddingCrypto.tsandroid/app/src/main/java/com/offlinefaceauth/EmbeddingCryptoModule.javasrc/utils/BufferUtils.ts
Embedding encryption uses AES-256-GCM over the raw 128-D float32 embedding.
Input:
- 128 float32 values
- 512 raw bytes
- base64 encoded at the TypeScript boundary
Encrypted blob:
[IV: 12 bytes][ciphertext: 512 bytes][GCM tag: 16 bytes]
Total size: 540 bytes.
AAD is the UTF-8 personnelId. This binds the encrypted embedding to its owner
row. If encrypted blobs are swapped between two personnel rows, decryption must
fail with a GCM authentication error.
EmbeddingCrypto.ts calls the native module when available and includes a
WebCrypto fallback for tests and compatible JS runtimes.
Main file:
src/storage/EnrollmentService.ts
Public API:
EnrollmentService.enroll({
personnelId,
name,
department,
embedding,
consentTs,
});Sequence:
- Generate a random 32-byte DEK with
crypto.getRandomValues, falling back to native secure random when needed. - Create the per-person hardware KEK with
generatePersonKey(personnelId). - Wrap the DEK with the per-person KEK into
kek_hw_wrapped. - Wrap the same DEK with the admin RSA-4096 public key into
kek_admin_wrapped. - Encrypt the 512-byte embedding with AES-256-GCM and
personnelIdas AAD. - Zero the DEK
Uint8Arrayimmediately after encryption. - Insert the personnel row and consent log row in one database transaction.
- If the DB write fails after the person key was created, delete the person key to avoid orphaned Keystore entries.
Main file:
src/storage/VerificationService.ts
Public API:
VerificationService.decryptEmbedding(personnelId);Sequence:
- Read
kek_hw_wrappedandencrypted_embedfrompersonnel. - Unwrap the DEK with the person's hardware KEK.
- Decrypt the embedding with AES-256-GCM and
personnelIdas AAD. - Clear the local DEK hex variable lifetime as much as JS allows.
- Return a
Float32Arraywith 128 values for M1 cosine similarity.
Note: JS strings are immutable, so a DEK hex string cannot be truly overwritten in-place. The implementation avoids caching DEKs and narrows their lifetime.
Main files:
src/crypto/AdminKey.tssrc/crypto/RSAOAEP.tssrc/crypto/AdminEscrow.ts
The enterprise admin RSA-4096 public key is bundled in AdminKey.ts. The private
key is generated locally as admin_private.pem and is ignored by git.
Admin escrow is for demo recovery only. It proves an embedding can be recovered from:
personnelIdkek_admin_wrappedencrypted_embed- the offline admin private key PEM
Production enrollment and verification paths must not call admin escrow.
AdminEscrow.recoverEmbedding logs a warning prefixed with:
[ADMIN ESCROW - DEMO ONLY]
Main migration file:
src/storage/database/migrations/002_person_embedding_crypto.ts
Adds:
personnel.encrypted_embedpersonnel.kek_hw_wrappedpersonnel.kek_admin_wrappedpersonnel.admin_key_versionpersonnel.enrollment_tspersonnel.consent_tsconsent_log
The migration is registered in:
src/storage/database/migrations/MigrationRunner.ts
Main test file:
tests/unit/storage/t3_2_embedding_crypto.test.ts
Coverage:
- Round-trip enrollment and verification.
- Row-swap attack rejection through AES-GCM AAD binding.
- DEK zeroing check at the JS buffer level.
- Admin escrow recovery independent of the hardware key.
- Wrong DEK decryption failure.
Existing SQLCipher tests were updated so the latest migration version is 2.
- Never log plaintext DEKs.
- Never store plaintext DEKs in MMKV, SQLite, files, or app state.
- Never reuse the same
(DEK, IV)pair for embedding encryption. - Never decrypt or return plaintext when AES-GCM authentication fails.
- Keep
offline_face_auth_db_v1andface_embed_key_{personnelId}aliases separate. - Keep
admin_private.pemout of git and out of the app bundle. - Do not pre-cache per-person DEKs for speed. Security wins over sub-5ms convenience if Keystore unwrap is the bottleneck.
- Android embedding crypto is implemented as a React Native native module using platform AES-GCM. The C++/BoringSSL TurboModule shape can replace this bridge later without changing the TypeScript API.
- iOS per-person wrapping uses Secure Enclave EC keys with ECIES AES-GCM where available, and Keychain fallback on Simulator. This matches the existing iOS secure-key pattern.
- Full DEK memory forensics cannot be proven from JS unit tests. Native memory profiling is needed for that level of assurance.