Extenderlo
Biometría y llavero
Dos plugins que van juntos — la comprobación biométrica del sistema, y secretos guardados donde el sistema guarda los suyos.
Dos plugins escritos fuera del núcleo, y la razón de que exista el mecanismo de permisos:
@angular-native/plugin-biometrics—LAContexten iOS,android.hardware.biometrics.BiometricPrompten Android.@angular-native/plugin-keychain— Keychain Services en iOS, una clave delAndroidKeyStoresobre un fichero privado de la app en Android.
Van juntos a propósito. Guardar algo «seguro» que cualquiera puede volver a leer
no protege de nada, y pedir la cara sin atarla a lo que descifra es teatro.
examples/secrets usa los dos.
Biometría
Sección titulada «Biometría»El diálogo lo dibuja el sistema. El plugin no ve nunca una cara ni una huella: solo cómo acabó.
const info = await biometrics.availability()// { status: 'available', kind: 'faceId', detail: 'LAContext.canEvaluatePolicy' }
const resultado = await biometrics.authenticate({ reason: 'Comprobar que eres tú', cancelTitle: 'Ahora no'})// { outcome: 'success', kind: 'faceId', detail: 'evaluatePolicy' }No contesta sí o no
Sección titulada «No contesta sí o no»Un booleano aplasta once situaciones en dos, y cuatro de ellas piden que la app
haga algo distinto: mandar a Ajustes, ofrecer el código, esconder el botón, o
volver a intentarlo. Así que authenticate() resuelve con un final:
outcome |
Qué pasó | Qué suele hacer una app |
|---|---|---|
success |
Autenticó. | Seguir. |
failed |
La biometría corrió, no reconoció a nadie y el sistema lo dio por terminado. | Ofrecer otro intento. |
userCancel |
El usuario cerró el diálogo o pulsó cancelar. | Nada. Ya lo sabe. |
userFallback |
Pidió el código del aparato y no estaba permitido. | Volver a llamar con allowDeviceCredential: true. |
systemCancel |
El sistema quitó el diálogo: la app se fue al fondo, entró una llamada. | Nada, o reintentar luego. |
timeout |
Se agotó el tiempo. Solo Android, ver abajo. | Ofrecer otro intento. |
noHardware |
Este aparato no tiene sensor. | No enseñar el botón. |
notEnrolled |
Hay sensor, pero no hay cara ni huella registrada. | Enlazar a Ajustes. |
passcodeNotSet |
El aparato no tiene código, y sin código no hay biometría. | Enlazar a Ajustes. |
lockedOut |
Demasiados intentos fallidos. | Ofrecer el código del aparato. |
permanentlyLockedOut |
Bloqueado hasta desbloquear el aparato con el código. Solo Android. | Ofrecer el código del aparato. |
unavailable |
Hay sensor y el sistema no lo presta. detail dice por qué. |
Caer a una contraseña. |
La promesa solo se rechaza cuando el fallo es de quien programó: un método
que no existe, un reason que falta, el plugin sin compilar dentro de la app.
Cancelar, fallar, no tener sensor y estar bloqueado son respuestas, no errores.
detail es lo que dijo el sistema: el código y el mensaje de LAError, o la
constante de BiometricManager. Es diagnóstico, no interfaz: está en inglés,
cambia entre versiones y no le explica nada a un usuario.
Dónde se separan de verdad las dos plataformas
Sección titulada «Dónde se separan de verdad las dos plataformas»Ninguna de estas se disimula, porque disimularla es mentir en una de las dos:
- Qué biometría es. iOS lo dice (
faceId,touchId,opticId); elBiometricManagerde Android contesta si se puede autenticar, no con qué, así que ahíkindesunknown. Rellenarlo confingerprintporque la mayoría de los Android lo son sería mentir justo en los que llevan cámara. - Bloqueo temporal y permanente. Android tiene dos códigos; iOS tiene uno
(
biometryLockout). Así quepermanentlyLockedOutsolo sale de Android. Fingirlo en iOS contando intentos sería inventárselo. - Un fallo suelto. En Android el diálogo sigue en pantalla y el plugin no
contesta: contestar cerraría la promesa con el diálogo abierto y el resultado
de verdad no tendría a quién llegar. En iOS el diálogo ofrece reintentar, y lo
que acaba llegando es
failed,userCancelo un tiempo agotado. - Tiempos agotados. Android manda
BIOMETRIC_ERROR_TIMEOUT, que se convierte entimeout. iOS emite unLAErrorcuyo código —el −1003— no está en el enumerado público, así que llega comounavailablecon"LAError -1003: Authentication timed out."dentro dedetail. Traducir un número que Apple no documenta sería adivinar; el mensaje es exacto. - Android necesita Android 10.
BiometricPromptllegó en Android 9, peroBiometricManager.canAuthenticate—lo único que sabe contestar aavailability()sin enseñar un diálogo— llegó en Android 10 (API 29). Por debajo, el plugin contestaunavailablecon el nivel de API dentro, en vez de disimular.
Llavero
Sección titulada «Llavero»await keychain.set('token-de-sesion', token, { requireBiometrics: true, reason: 'Guardar el testigo de sesión protegido con tu cara'})
const lectura = await keychain.get('token-de-sesion', { reason: 'Enseñar el testigo' })// { outcome: 'found', value: '…', detail: 'SecItemCopyMatching' }has() y remove() no preguntan nunca: saber que un elemento existe no es
abrirlo, y el llavero deja tirar un elemento que no se puede leer —menos mal, o
un secreto atado a una huella borrada se quedaría ahí para siempre—.
Una lectura contesta found, notFound, denied, invalidated o
unavailable. notFound y denied no son lo mismo, y aplastarlos es el error
clásico de este tipo de API: el primero quiere decir que hay que volver a pedir
la contraseña, y el segundo que el secreto sigue ahí y quien tiene el teléfono
no ha demostrado ser su dueño. Cerrar la sesión en el segundo caso es castigar
al usuario por haber cancelado un diálogo.
Qué protege de verdad
Sección titulada «Qué protege de verdad»iOS — Keychain Services. Cada secreto es un kSecClassGenericPassword con
el identificador del bundle como servicio.
| Pregunta | Respuesta |
|---|---|
| ¿Lo lee otra app? | No. Los grupos del llavero separan las apps. |
| ¿Se lee el fichero sacándolo del aparato? | No. Cifrado en reposo, legible solo tras el primer desbloqueo (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly). |
| ¿Sobrevive a desinstalar la app? | Sí. iOS no borra los elementos del llavero al quitar una app. Se reinstala y siguen ahí. Si eso importa, hay que borrarlos al cerrar sesión. |
| ¿Viaja en la copia de seguridad? | No. El …ThisDeviceOnly lo deja fuera del llavero de iCloud y de las copias cifradas. |
| ¿Sobrevive a un cambio de terminal? | No, por lo mismo. |
¿Con requireBiometrics? |
El elemento queda atado al juego registrado (.biometryCurrentSet). Se registra otra cara e iOS tira la clave: el elemento desaparece y una lectura contesta notFound. Es lo que impide que alguien con el código del aparato añada su propia cara y se sirva. |
| ¿Un aparato con jailbreak, un depurador, o la app soltando el valor tras leerlo? | No. Nada de esto protege un aparato comprometido ni una app descuidada. |
Android — no hay llavero. Android no tiene ninguna API que guarde un secreto
por ti; lo que tiene es un almacén de claves que guarda claves y no las suelta.
Así que el equivalente se arma con dos piezas: una clave AES-256-GCM generada
dentro del AndroidKeyStore, que nunca sale de él, y el secreto cifrado con ella
en un fichero de preferencias MODE_PRIVATE.
| Pregunta | Respuesta |
|---|---|
| ¿Lo lee otra app? | No. El fichero es privado y la clave no se puede exportar. |
| ¿La clave está en hardware? | Depende del teléfono. TEE o StrongBox donde lo hay, software donde no. keychain.backing() lo pregunta y lo dice, porque no es la misma garantía y una app seria puede querer negarse a la floja. |
| ¿Sobrevive a desinstalar la app? | No. Se van el fichero y la clave. |
| ¿Viaja en la copia de seguridad? | El fichero cifrado puede. La clave nunca, así que restaurado en otro aparato no se abre. |
| ¿Sobrevive a un cambio de terminal? | No. |
¿Con requireBiometrics? |
La clave se genera con setUserAuthenticationRequired(true) y setInvalidatedByBiometricEnrollment(true), y cada uso pasa por BiometricPrompt con un CryptoObject. Se registra otra huella y la clave se destruye; lo guardado se queda y ya no se puede abrir nunca: es el final invalidated. |
| ¿Un aparato con root? | Más flojo. Con hardware detrás la clave sigue sin poderse extraer, pero alguien con root puede pedirle al almacén que la use. Con almacén de software, se la lleva. |
Una diferencia que hay que saber
Sección titulada «Una diferencia que hay que saber»En iOS guardar no pregunta y leer sí. En Android guardar también pregunta. El
almacén de claves de Android ata la autenticación a cada uso de la clave, y
cifrar es un uso. No es un descuido y no está escondido: está escrito en el
contrato de TypeScript, al lado de requireBiometrics. Esconderlo obligaría a
cifrar con otra clave, y entonces la protección sería otra.
La otra diferencia es invalidated: Android dice que la entrada quedó ilegible
para siempre, mientras que iOS la borra y contesta notFound.
Qué está verificado, y dónde
Sección titulada «Qué está verificado, y dónde»- iOS, en el simulador del iPhone 17 Pro, visto funcionando: qué biometría
tiene el aparato y si hay algo registrado; una cara que coincide autenticando;
una cara que no coincide rechazada por el sistema sin enseñar nada; un secreto
guardado con
SecItemAddy leído conSecItemCopyMatching; y elNSFaceIDUsageDescriptiony elkeychain-access-groupsque hacen posibles las dos cosas, fundidos porandentro de la app. - No verificable en el simulador: el simulador no aplica el
SecAccessControl. Un elemento guardado con.biometryCurrentSetlo devuelveSecItemCopyMatchingsin enseñar Face ID. El control de acceso está puesto en el elemento —el código que lo pone es el mismo que corre en un aparato—, pero la puerta solo es de verdad sobre hardware con Secure Enclave. - Android: el Java de los dos plugins compila contra
android.jar, y la fusión del manifiesto dice qué permiso y qué característica añade. Nada más. El APK entero no llegó a armarse —la máquina se quedó sin disco enaapt2 link— y no se arrancó ningún emulador. Nadie ha visto nada de esto funcionar en Android.
Las capturas
Sección titulada «Las capturas»Todavía no hay nada registrado, y la app lo dice en vez de enseñar un botón que iba a fallar:

Una cara que coincide — Features › Face ID › Matching Face en el simulador:

Una que no. El sistema la rechaza, la app sigue esperando y la caja sigue vacía:

Guardar un secreto, y volver a leerlo:

