सामग्री पर जाएं
सभी पोस्ट

2026 में Android पर बायोमेट्रिक ऑथेंटिकेशन: BiometricPrompt, Keystore, और एक लोकल-फर्स्ट ऐप को लॉक करना

androidx.biometric की व्यावहारिक गाइड — BiometricPrompt, CryptoObject-समर्थित keys, डिवाइस credential fallback, और वे गलतियाँ जिनकी वजह से एक फिंगरप्रिंट चेक असल में कुछ भी सुरक्षित नहीं करता।

MFKAPPS 6 मिनट पढ़ना

एक फिंगरप्रिंट प्रॉम्प्ट जो सिर्फ एक स्क्रीन को गेट करता है, वह सजावट भर है। अगर underlying डेटा उस पूरे समय एक plain Room database में पड़ा है, तो adb और एक rooted डिवाइस — या एक backup extraction टूल — रखने वाला कोई भी व्यक्ति सेंसर को छुए बिना ही उसे पढ़ लेता है। Android पर असली बायोमेट्रिक सुरक्षा का मतलब है कि प्रॉम्प्ट एक cryptographic key को अनलॉक करता है, और वही key असल में attacker और डेटा के बीच खड़ी होती है। यही वह हिस्सा है जिसे ज़्यादातर BiometricPrompt ट्यूटोरियल छोड़ देते हैं, और यही एकमात्र हिस्सा है जो मायने रखता है।

मैंने इसे Granyn में जोड़ा ताकि डिवाइस पर बजट डेटा को लॉक किया जा सके, और यही पैटर्न किसी भी लोकल-फर्स्ट ऐप में मेडिकेशन लॉग या सब्सक्रिप्शन लिस्ट को सुरक्षित रखता है। यहाँ वह वर्ज़न है जो असल में काम करता है।

“बायोमेट्रिक ऑथ” कहलाने वाली दो अलग चीज़ें

androidx.biometric दो ऑथेंटिकेशन मोड सपोर्ट करता है, और ये पूरी तरह अलग-अलग चीज़ों की सुरक्षा करते हैं:

  • सिर्फ ऑथेंटिकेशन। बिना CryptoObject के BiometricPrompt.authenticate(PromptInfo) सिर्फ यह पूछता है “क्या registered बायोमेट्रिक मैच हुआ?” और हाँ या ना में जवाब देता है। यह एक UI गेट है। कुछ भी encrypt या decrypt नहीं होता — एक rooted डिवाइस या एक content provider बग इसे पूरी तरह बायपास कर देता है, क्योंकि डेटा कभी भी प्रॉम्प्ट के सफल होने पर निर्भर नहीं था।
  • Crypto-समर्थित ऑथेंटिकेशन। BiometricPrompt.authenticate(PromptInfo, CryptoObject) प्रॉम्प्ट को एक Cipher से जोड़ता है, जो एक Android Keystore key से बना होता है और जिसे setUserAuthenticationRequired(true) से फ़्लैग किया गया है। OS खुद उस key को कुछ भी करने से — encrypt, decrypt, sign — तब तक मना करता है जब तक एक मैचिंग बायोमेट्रिक या डिवाइस credential ऑथेंटिकेशन अभी-अभी सफल न हुआ हो। key बिना प्रॉम्प्ट के केवल परंपरागत रूप से गेटेड नहीं, बल्कि physically अनुपयोगी हो जाती है।

अगर लक्ष्य है “बजट के आंकड़ों को अनलॉक किए बिना अपठनीय रखना”, तो सिर्फ दूसरा तरीका ही ऐसा करता है। पहला तरीका कम-जोखिम वाली किसी चीज़ के लिए ठीक है, जैसे किसी ऐसी स्क्रीन पर एक destructive action से पहले फिर से confirm करना जो at-rest sensitive डेटा हैंडल नहीं करती।

एक Keystore key बनाना जिसे ऑथेंटिकेशन की ज़रूरत हो

private const val KEY_ALIAS = "granyn_db_key"

fun getOrCreateKey(): SecretKey {
    val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }

    val keyGenerator = KeyGenerator.getInstance(
        KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
    )
    val spec = KeyGenParameterSpec.Builder(
        KEY_ALIAS,
        KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
    )
        .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
        .setUserAuthenticationRequired(true)
        .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
        .build()
    keyGenerator.init(spec)
    return keyGenerator.generateKey()
}

setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG) सबसे ज़रूरी लाइन है: duration का 0 होना मतलब यह है कि key हर ऑथेंटिकेशन पर ठीक एक operation के लिए usable है, न कि किसी rolling time window के लिए। यह पुराने setUserAuthenticationValidityDurationSeconds से जानबूझकर ज़्यादा सख़्त है, जो किसी भी सफल चेक के बाद key को N सेकंड तक अनलॉक रहने देता था — सुविधाजनक था, और साथ ही यही वजह भी थी कि कई “बायोमेट्रिक-प्रोटेक्टेड” ऐप्स तब चुपचाप कुछ भी सुरक्षित नहीं कर रहे होते थे जब कोई background service दूसरी बार उस key को छूती थी। खासतौर पर BIOMETRIC_STRONG की मांग करें; BIOMETRIC_WEAK उन चीज़ों को कवर करता है जैसे किसी ऐसे angle से चेहरे से अनलॉक करना जिसे कभी-कभी एक फोटो धोखा दे सकती है — यह वह bar नहीं है जो आप financial डेटा की रक्षा के लिए चाहते हैं।

प्रॉम्प्ट को cipher से जोड़ना

suspend fun authenticateAndDecrypt(
    activity: FragmentActivity,
    ciphertext: ByteArray,
    iv: ByteArray,
): ByteArray = suspendCancellableCoroutine { cont ->
    val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
        init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(128, iv))
    }

    val callback = object : BiometricPrompt.AuthenticationCallback() {
        override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
            val decrypted = result.cryptoObject?.cipher?.doFinal(ciphertext)
            if (decrypted != null) cont.resume(decrypted) else cont.cancel()
        }
        override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
            cont.cancel()
        }
    }

    val prompt = BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), callback)
    val promptInfo = BiometricPrompt.PromptInfo.Builder()
        .setTitle("Unlock Granyn")
        .setSubtitle("Confirm it's you to view your budget")
        .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
        .build()

    prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}

यहाँ दो चीज़ें लोगों को अटकाती हैं। पहली, setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) — जो strong बायोमेट्रिक को डिवाइस के PIN/pattern/password fallback के साथ जोड़ता है — setNegativeButtonText() के साथ mutually exclusive है। दोनों को एक साथ कॉल करने पर प्रॉम्प्ट build टाइम पर throw करता है। डिवाइस credential ऑप्शन पहले से ही यूज़र्स को एक रास्ता देता है जब सेंसर fail हो या कुछ भी enrolled न हो, इसलिए एक manual negative button उसके साथी होने के बजाय उसके साथ redundant है।

दूसरी, वह CryptoObject जो आपको onAuthenticationSucceeded में वापस मिलता है, वह कोई courtesy copy नहीं है — यह वही Cipher instance है, अब अनलॉक्ड, जिस पर आपको तुरंत doFinal() कॉल करना चाहिए। callback के फायर होने के बाद एक नया Cipher फिर से बनाना पूरे मकसद को ही बेकार कर देता है: वह नया cipher कभी भी ऑथेंटिकेशन चेक से approved नहीं हुआ था, इसलिए उसे इस्तेमाल करने की कोशिश करने पर UserNotAuthenticatedException throw होता है — या इससे भी बुरा, अगर आपने इसे auth requirement के बिना बनाया, तो यह चुपचाप काम करता है और उस छेद को फिर से खोल देता है जिसे आप बंद कर रहे थे।

प्रॉम्प्ट दिखाने से पहले उपलब्धता जांचना

val biometricManager = BiometricManager.from(context)
when (biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)) {
    BiometricManager.BIOMETRIC_SUCCESS -> { /* आगे बढ़ें */ }
    BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
        // न फिंगरप्रिंट/फेस है और न ही PIN/pattern/password सेट है। सिर्फ
        // authenticate() कॉल करके इसे चुपचाप fail होने देने के बजाय
        // Settings.ACTION_BIOMETRIC_ENROLL पर भेजें।
    }
    BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
    BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
        // खुद मैनेज किए गए app-level PIN पर fallback करें, या access ब्लॉक
        // करने के बजाय इस डिवाइस पर lock फीचर को पूरी तरह छोड़ दें।
    }
    else -> { /* बाकी error codes हैंडल करें */ }
}

canAuthenticate() कोई optional boilerplate नहीं है — यही फर्क तय करता है एक साफ़ “इसे enable करने के लिए एक screen lock सेट करें” प्रॉम्प्ट और एक confusing failure के बीच, जब कोई enrolled credential न रखने वाला यूज़र पहली बार toggle टैप करता है। इस path को जानबूझकर टेस्ट करें: emulator के enrolled fingerprints मिटाएं (Settings → Security → Fingerprint, या emulator के extended controls में Fingerprint पैनल से), और confirm करें कि ऐप crash होने के बजाय ठीक से degrade होता है।

असल में लॉक की ज़रूरत किसे है

हर चीज़ को इससे फ़ायदा नहीं होता। एक crypto-backed key असली friction जोड़ती है — हर decrypt पर एक ऑथेंटिकेशन, हर read पर एक Keystore round trip — इसलिए पूरे database read path को इससे gate करना “ऐप खोलने” को काफ़ी धीमा operation बना देता है। Granyn के लिए मैंने जो तय किया वह यह है: सिर्फ उन fields को encrypt करना जो फ़ोन खो जाने पर मायने रखती हैं — account balances और transaction notes — और category names व UI preferences को plain Room columns में ही छोड़ देना। lock वही सुरक्षित करता है जो असल में sensitive है; बाकी सब कुछ तेज़ रहता है।

इस सबसे जो आदत बनाए रखने लायक है वह यह है: कोई भी “आपके फिंगरप्रिंट से सुरक्षित” फीचर शिप करने से पहले पूछें कि अगर प्रॉम्प्ट को पूरी तरह skip कर दिया जाए तो क्या होता है — root, एक debugger, एक content-provider bypass। अगर जवाब है “कुछ नहीं, डेटा अभी भी पढ़ने लायक है”, तो वह प्रॉम्प्ट कभी भी वह काम नहीं कर रहा था जो वह करता हुआ दिखता था।

// संबंधित पठन

जर्नल से और भी

MFKAPPS 5 मिनट पढ़ना

2026 में Android पर Health Connect: लोकल-फर्स्ट को छोड़े बिना डेटा पढ़ना और लिखना

2026 में Android के Health Connect API की एक व्यावहारिक गाइड — परमिशन, बैकग्राउंड रीड्स, और लोकल-फर्स्ट ऐप्स को इसे सच नहीं, बल्कि वैकल्पिक क्यों मानना चाहिए।

#android #engineering #kotlin
MFKAPPS 5 मिनट पढ़ना

Android का In-App Review API: बिना परेशान किए रेटिंग माँगना

Google के In-App Review API की एक व्यावहारिक गाइड — यह असल में कैसे काम करता है, इसे कब ट्रिगर करना चाहिए, और सामान्य 'हमें रेट करें' पॉपअप आपकी Play Store रेटिंग को चुपचाप कैसे नुकसान पहुँचा रहा है।

#android #engineering #kotlin
MFKAPPS 6 मिनट पढ़ना

Android ऐप शॉर्टकट्स और Quick Settings Tiles: बिना ऐप खोले पानी लॉग करना

Android के डायनामिक ShortcutManager API और TileService की एक व्यावहारिक गाइड — कैसे एक-टैप एक्शन को पूरी तरह ऐप को छोड़कर काम करने दें, साथ ही वे गड़बड़ियाँ जो ज़्यादातर इम्प्लीमेंटेशन को फँसा देती हैं।

#android #engineering #kotlin