Android Fingerprint API: приделываем аутентификацию по отпечатку
Прошло достаточно много времени, как появился Fingerprint API для Android, в сети много разрозненных сэмплов кода по его внедрению и использованию, но на Хабре по какой-то причине эту тему обходили стороной. На мой взгляд, настало время исправить это недоразумение.
Всех заинтересовавшихся прошу под кат.
Кратчайший ликбез
Итак, что же представляет собой Fingerprint API?
API позволяет пользователю аутентифицироваться посредством своего отпечатка, очевидно.
Для работы с сенсором API предлагает нам FingerprintManager, достаточно простой в освоении.
Как его использовать?
А вот это уже интереснее.
Практически везде, где требуется аутентификация по паролю, можно прикрутить аутентификацию по отпечатку.
Представьте себе приложение, состоящее из LoginActivity и MainActivity. При запуске мы попадаем на экран логина, вводим пин-код, проходим к данным. Но мы хотим заменить вход по пин-коду на вход по отпечатку.
К слову, полностью заменить не получится, мы можем лишь избавить пользователя от ручного ввода пин-кода, подставляя ранее сохраненный пин-код (имеется ввиду клиент-серверное приложение, в котором нужно отправить пароль серверу).
Приступим.
Где сенсор?
Чтобы начать получать профит от нового API, первым делом нужно добавить permission в манифесте:
Само собой, использовать Fingerprint API можно только на устройствах, его поддерживающих: соответственно, это устройства Android 6+ с сенсором.
Совместимость можно легко проверить с помощью метода:
public static boolean checkFingerprintCompatibility(@NonNull Context context) {
return FingerprintManagerCompat.from(context).isHardwareDetected();
}
FingerprintManagerCompat — это удобная обертка для обычного FingerprintManager«а, которая упрощает проверку устройства на совместимость, инкапсулируя в себе проверку версии API. В данном случае, isHardwareDetected () вернет false, если API ниже 23.
Дальше, нам нужно понять, готов ли сенсор к использованию. Для этого определим enum состояний:
public enum SensorState {
NOT_SUPPORTED,
NOT_BLOCKED, // если устройство не защищено пином, рисунком или паролем
NO_FINGERPRINTS, // если на устройстве нет отпечатков
READY
}
И воспользуемся методом:
public static SensorState checkSensorState(@NonNull Context context) {
if (checkFingerprintCompatibility(context)) {
KeyguardManager keyguardManager =
(KeyguardManager) context.getSystemService(KEYGUARD_SERVICE);
if (!keyguardManager.isKeyguardSecure()) {
return SensorState.NOT_BLOCKED;
}
FingerprintManagerCompat fingerprintManager = FingerprintManagerCompat.from(context);
if (!fingerprintManager.hasEnrolledFingerprints()) {
return SensorState.NO_FINGERPRINTS;
}
return SensorState.READY;
} else {
return SensorState.NOT_SUPPORTED;
}
}
Код достаточно тривиальный. Небольшое недопонимание может вызвать момент, когда мы проверяем заблокировано ли устройство. Нам нужна эта проверка, так как, хоть Android и не позволяет добавлять отпечатки в незащищенное устройство, некоторые производители это обходят, поэтому подстраховаться не помешает.
Различные состояния можно использовать для того, чтобы дать пользователю понять, что происходит и направить его на путь истинный.
Подготовка
Итак, не зацикливаясь на проверке пин-кода на валидность, прикинем следующую упрощенную логику действий:
- Пользователь вводит пин-код, если SensorState.READY, то мы сохраняем пин-код, запускаем MainActivity.
- Рестартим приложение, если SensorState.READY, то считываем отпечаток, достаем пин-код, имитируем его ввод, запускаем MainActivity.
Схема была бы достаточно простой, если бы не одно но: Гугл настойчиво рекомендует не хранить приватные данные пользователя в открытом виде. Поэтому нам нужен механизм шифровки и расшифровки для, соответственно, сохранения и использования. Займемся этим.
Что нам нужно для шифровки и расшифровки:
- Защищенное хранилище для ключей.
- Криптографический ключ.
- Шифровальщик
Хранилище
Для работы с отпечатками система предоставляет нам свой кейстор — «AndroidKeyStore» и гарантирует защиту от несанкционированного доступа. Воспользуемся им:
private static KeyStore sKeyStore;
private static boolean getKeyStore() {
try {
sKeyStore = KeyStore.getInstance("AndroidKeyStore");
sKeyStore.load(null);
return true;
} catch (KeyStoreException | IOException | NoSuchAlgorithmException | CertificateException e) {
e.printStackTrace();
}
return false;
}
Следует принять, понять и простить, что кейстор хранит только криптографические ключи. Пароли, пин и другие приватные данные там хранить нельзя.
Ключ
На выбор у нас два варианта ключей: симметричный ключ и пара из публичного и приватного ключа. Из соображений UX мы воспользуемся парой. Это позволит нам отделить ввод отпечатка от шифрования пин-кода.
Ключи мы будем доставать из кейстора, но сначала нужно их туда положить. Для создания ключа воспользуемся генератором.
private static KeyPairGenerator sKeyPairGenerator;
private static boolean getKeyPairGenerator() {
try {
sKeyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore");
return true;
} catch (NoSuchAlgorithmException | NoSuchProviderException e) {
e.printStackTrace();
}
return false;
}
При инициализации мы указываем, в какой кейстор пойдут сгенерированные ключи и для какого алгоритма предназначен этот ключ.
Сама же генерация происходит следующим образом:
private static boolean generateNewKey() {
if (getKeyPairGenerator()) {
try {
sKeyPairGenerator.initialize(new KeyGenParameterSpec.Builder(KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
.setUserAuthenticationRequired(true)
.build());
sKeyPairGenerator.generateKeyPair();
return true;
} catch (InvalidAlgorithmParameterException e) {
e.printStackTrace();
}
}
return false;
}
Здесь следует обратить внимание на два места:
- KEY_ALIAS — это псевдоним ключа, по которому мы будем выдергивать его из кейстора, обычный psfs.
- .setUserAuthenticationRequired (true) — этот флаг указывает, что каждый раз, когда нам нужно будет воспользоваться ключом, нужно будет подтвердить себя, в нашем случае — с помощью отпечатка.
Проверять наличие ключа будем следующим образом:
private static boolean isKeyReady() {
try {
return sKeyStore.containsAlias(KEY_ALIAS) || generateNewKey();
} catch (KeyStoreException e) {
e.printStackTrace();
}
return false;
}
Шифровальщик
Шифровкой и дешифровкой в Java занимается объект Cipher. Инициализируем его:
private static Cipher sCipher;
private static boolean getCipher() {
try {
sCipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
return true;
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
e.printStackTrace();
}
return false;
}
Адовая мешанина в аргументе — это строка трансформации, которая включает в себя алгоритм, режим смешивания и дополнение.
После того, как мы получили Cipher, нужно подготовить его к работе.
При генерации ключа мы указали, что будем использовать его только для шифровки и расшифровки. Соответственно, Cipher тоже будет для этих целей:
private static boolean initCipher(int mode) {
try {
sKeyStore.load(null);
switch (mode) {
case Cipher.ENCRYPT_MODE:
initEncodeCipher(mode);
break;
case Cipher.DECRYPT_MODE:
initDecodeCipher(mode);
break;
default:
return false; //this cipher is only for encode\decode
}
return true;
} catch (KeyPermanentlyInvalidatedException exception) {
deleteInvalidKey();
} catch (KeyStoreException | CertificateException | UnrecoverableKeyException | IOException |
NoSuchAlgorithmException | InvalidKeyException | InvalidKeySpecException | InvalidAlgorithmParameterException e) {
e.printStackTrace();
}
return false;
}
где initDecodeCipher () и initEncodeCiper () следующие:
private static void initDecodeCipher(int mode) throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException, InvalidKeyException {
PrivateKey key = (PrivateKey) sKeyStore.getKey(KEY_ALIAS, null);
sCipher.init(mode, key);
}
private static void initEncodeCipher(int mode) throws KeyStoreException, InvalidKeySpecException, NoSuchAlgorithmException, InvalidKeyException, InvalidAlgorithmParameterException {
PublicKey key = sKeyStore.getCertificate(KEY_ALIAS).getPublicKey();
PublicKey unrestricted = KeyFactory.getInstance(key.getAlgorithm()).generatePublic(new X509EncodedKeySpec(key.getEncoded()));
OAEPParameterSpec spec = new OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT);
sCipher.init(mode, unrestricted, spec);
}
Нетрудно заметить, что зашифровывающий Cipher несколько сложнее инициализировать. Это косяк самого Гугла, суть которого в том, что публичный ключ требует подтверждения пользователя. Мы обходим это требование с помощью слепка ключа (костыль, ага).
Момент с KeyPermanentlyInvalidatedException — если по какой-то причине ключ нельзя использовать, выстреливает это исключение. Возможные причины — добавление нового отпечатка к существующему, смена или полное удаление блокировки. Тогда ключ более не имеет смысла хранить, и мы его удаляем.
public static void deleteInvalidKey() {
if (getKeyStore()) {
try {
sKeyStore.deleteEntry(KEY_ALIAS);
} catch (KeyStoreException e) {
e.printStackTrace();
}
}
}
Метод, который собирает всю цепочку подготовки:
private static boolean prepare() {
return getKeyStore() && getCipher() && isKeyReady();
}
Шифровка и расшифровка
Опишем метод, который зашифровывает строку аргумент:
public static String encode(String inputString) {
try {
if (prepare() && initCipher(Cipher.ENCRYPT_MODE)) {
byte[] bytes = sCipher.doFinal(inputString.getBytes());
return Base64.encodeToString(bytes, Base64.NO_WRAP);
}
} catch (IllegalBlockSizeException | BadPaddingException exception) {
exception.printStackTrace();
}
return null;
}
В результате мы получаем Base64-строку, которую можно спокойно хранить в преференсах приложения.
Для расшифровки же используем следующий метод:
public static String decode(String encodedString, Cipher cipherDecrypter) {
try {
byte[] bytes = Base64.decode(encodedString, Base64.NO_WRAP);
return new String(cipherDecrypter.doFinal(bytes));
} catch (IllegalBlockSizeException | BadPaddingException exception) {
exception.printStackTrace();
}
return null;
}
Опа, на вход он получает не только зашифрованную строку, но и объект Cipher.
Откуда он там взялся, станет ясно позднее.
Не тот палец
Для того чтобы наконец использовать сенсор, нужно воспользоваться методом FingerprintManagerCompat:
void authenticate (FingerprintManagerCompat.CryptoObject crypto,
CancellationSignal cancel,
int flags,
FingerprintManagerCompat.AuthenticationCallback callback,
Handler handler)
Хендлер и флаги нам сейчас не нужны, сигнал используется, чтобы отменить режим считывания отпечатков (при сворачивании приложения, например), коллбеки возвращают результат конкретного считывания, а вот над криптообъектом остановимся поподробнее.
CryptoObject в данном случае используется как обертка для Cipher’a. Чтобы его получить, используем метод:
public static FingerprintManagerCompat.CryptoObject getCryptoObject() {
if (prepare() && initCipher(Cipher.DECRYPT_MODE)) {
return new FingerprintManagerCompat.CryptoObject(sCipher);
}
return null;
}
Как видно из кода, криптообъект создается из расшифровывающего Cipher.
Если этот Cipher прямо сейчас отправить в метод decode (), то вылетит исключение, оповещающее о том, что мы пытаемся использовать ключ без подтверждения.
Строго говоря, мы создаем криптообъект и отправляем его на вход в authenticate () как раз для получения этого самого подтверждения.
Если getCryptoObject () вернул null, то это значит, что при инициализации Chiper'а произошел KeyPermanentlyInvalidatedException. Тут уже ничего не поделаешь, кроме как дать пользователю знать, что вход по отпечатку недоступен и ему придется заново ввести пин-код.
Как я уже говорил, результаты считывания сенсора мы получаем в методах коллбека. Вот как они выглядят:
@Override
public void onAuthenticationHelp(int helpCode, CharSequence helpString) {
//грязные пальчики, недостаточно сильный зажим
//можно показать helpString в виде тоста
}
@Override
public void onAuthenticationFailed() {
//отпечаток считался, но не распознался
}
@Override
public void onAuthenticationError(int errorCode, CharSequence errString) {
//несколько неудачных попыток считывания (5)
//после этого сенсор станет недоступным на некоторое время (30 сек)
}
@Override
public void onAuthenticationSucceeded(@NonNull FingerprintManagerCompat.AuthenticationResult result) {
//все прошло успешно
}
В случае успешного распознавания мы получаем AuthenticationResult, из которого можем достать объект Cipher c уже подтвержденным ключом:
result.getCryptoObject().getCipher()
Теперь можно с чистой совестью отправить его на вход в decode (), получить пин-код, сымитировать его ввод и показать пользователю его данные.
На этом на сегодня всё, замечания, комментарии, предложения и вопросы приветствуются.
Простейший вариант работы кода можете посмотреть на гитхабе.
Спасибо за внимание.