Desde el pasado 2 de abril, es posible acceder a la aplicación MiDNI, que permite a los ciudadanos realizar trámites presenciales como si del DNI físico se tratase; desde recoger paquetes hasta firmar una hipoteca, todo respaldado por una nueva legislación que le otorga este estatus.

El mismo día que fue lanzado me acerqué a una comisaria para activarlo, y ya por fin dispongo de él en mi dispositivo móvil, algo que parece la evolución natural de este tipo de documentos. Su uso es muy sencillo, y permite compartir con las autoridades o con quien lo requiera, diferentes niveles de información según la situación:

  • DNI Edad: muestra únicamente la foto, el número de DNI, y certifica si eres o no mayor de edad.
  • DNI Simple: muestra la foto, el número de DNI, el nombre completo, la fecha de nacimiento, género, y fecha de caducidad del documento.
  • DNI Completo: muestra todos los detalles del DNI (solo debería utilizarse ante la Administración o cuerpos de seguridad del Estado).

Además, la propia aplicación incluye una característica que permite verificar el DNI de otras personas, permitiendo escanear los códigos QR para validar e identificar a la persona que estemos escaneando.

Esto último me creó cierta curiosidad, ya que, al igual que esta aplicación ofrece esta característica, ¿por qué no la pueden usar otras aplicaciones para identificar y verificar a las personas?

Sé que todavía este DNI no es válido para realizar trámites en línea y que a lo largo de 2026 esto será posible, pero si te paras a pensar, hay muchísimas aplicaciones que se le pueden dar a esta característica actualmente: implementación de verificación en software dedicado a eventos (discotecas, festivales, etc.), controles de seguridad físicos automatizados, etc.

No obstante, a todo esto le veía un problema: actualmente la aplicación MiDNI te obliga a estar autenticado (debes iniciar sesión) para poder escanear el código QR de otra persona. Esto lo veo como una oportunidad perdida, ya que si me pongo en la piel de una empresa, no puedo obligar a mis trabajadores a tener esta aplicación en un dispositivo corporativo compartido con su sesión de MiDNI.

Esto invalida automáticamente cualquiera de los casos de uso que he presentado anteriormente, y otros muchos que se me pueden estar escapando. Pudiendo aprovechar la oportunidad de un sistema tan potente, me empecé a preguntar si existía algún tipo de documentación para desarrolladores donde pudiera comprobar si el sistema de verificación se pudiese implementar externamente.

Como era de esperar, al menos a día de hoy, no he sido capaz de encontrar ningún documento técnico que explique el funcionamiento de la aplicación MiDNI: cómo se genera el código QR, y cómo la propia aplicación lo decodifica. Si algún lector de este artículo lo encuentra, que no dude en compartirlo conmigo.

De todas formas, me propuse investigar el funcionamiento de este proceso, y empecé a trabajar en programar una implementación que permitiese decodificar y validar el código QR, sin necesidad de disponer de la app de MiDNI ni iniciar sesión en ella.

Funcionamiento de la app MiDNI

Para poder programar esta implementación, primero había que hacerse un esquema a alto nivel de cómo estaba funcionando la aplicación, y cómo creía que se estaba realizando la verificación del contenido.

En primer lugar, es obvio que la aplicación trata de dar una validez limitada a los códigos que generamos, haciendo que se muestre durante no más de 1 minuto. Si intentas escanear el código QR pasado ese tiempo, la validación por parte de la aplicación falla.

Sin embargo, una marca temporal no puede ser el único factor que da validez al código QR generado. Además, la decodificación de los QR no debería necesitar conexión a internet, ni verificar los datos contra ningún servicio, ya que eso no sería viable a una gran escala.

El servidor del Ministerio del Interior que obtiene los datos del ciudadano y genera el código QR debe firmar la petición utilizando una clave privada, que la app de MiDNI es capaz de verificar utilizando la clave pública de dichos certificados.

En resumidas cuentas y para que se entienda, es como si nos mandásemos una carta donde te digo qué letras debes cambiar por otras para "encriptarla", y cuando me la envíes, soy el único que conoce el patrón para "desencriptarla".

En este caso, el Ministerio del Interior tiene ese "patrón" secreto, y por tanto, es el único que puede firmar los códigos QR que se generan. No obstante, la aplicación, tiene esa equivalencia que nos permite verificar que, efectivamente, ha sido el Ministerio del Interior quien ha creado ese contenido.

Un poquito de ingeniería inversa

Teniendo claro el funcionamiento a grandes rasgos, era hora de remangarse y ponerse manos a la obra para entender la maraña de datos que arrojaba el propio código QR (con el nivel de información completa) al obtener los datos crudos.

0000: DC 03 81 12 54 37 A1 C6 22 B7 19 6F 34 B0 92 3D  | ....T7.."..o4..=
0010: 14 6A 28 9F 30 1B 47 E5 22 5D 18 9C 45 A6 12 F8  | .j(.0.G."]..E...
0020: 83 34 1A 4D 62 18 BD 45 1C 5E 23 08 09 45 33 AF  | .4.Mb..E.^#..E3.
0030: 60 16 43 2E 20 41 56 44 41 20 44 45 20 4C 41 20  | `.C. AVDA DE LA 
0040: 50 41 5A 20 31 35 20 50 30 32 20 41 72 06 42 41  | PAZ 15 P02 Ar.BA
0050: 52 43 45 4C 4F 4E 41 74 06 42 41 52 43 45 4C 4F  | RCELONAt.BARCELO
0060: 4E 41 62 08 42 41 52 43 45 4C 4F 4E 41 78 07 43  | NAb.BARCELONAx.C
0070: 41 54 41 4C 55 D1 41 64 03 45 53 50 66 12 4D 41  | ATALU.Ad.ESPf.MA
0080: 4E 55 45 4C 20 2F 20 50 49 4C 41 52 20 20 20 20  | NUEL / PILAR    
0090: 20 40 09 32 35 31 39 38 37 36 33 4B 57 42 0A 31  |  @.25198763KWB.1
00A0: 30 2D 30 36 2D 31 39 39 32 44 05 4C 41 55 52 41  | 0-06-1992D.LAURA
00B0: 46 0D 47 41 52 43 49 41 20 4C 4F 50 45 5A 48 01  | F.GARCIA LOPEZH.
00C0: 46 4C 0A 30 38 2D 30 33 2D 32 30 33 30 50 82 03  | FL.08-03-2030P..
00D0: 68 00 00 00 0C 6A 50 20 20 0D 0A 87 0A 00 00 00  | h....jP  .......
00E0: 14 66 74 79 70 6A 70 32 20 00 00 00 00 6A 70 32  | .ftypjp2 ....jp2
00F0: 20 00 00 00 2D 6A 70 32 68 00 00 00 16 69 68 64  |  ...-jp2h....ihd
0100: 72 00 00 01 AC 00 00 01 90 00 01 07 07 00 00 00  | r...............
0110: 00 00 0F 63 6F 6C 72 01 00 00 00 00 00 11 00 00  | ...colr.........
0120: 03 1B 6A 70 32 63 BF 4F BF 51 00 29 00 00 00 00  | ..jp2c.O.Q.)....

Entre todo este lío (es información inventada como ejemplo), era capaz de identificar a simple vista el número de DNI, la dirección completa, una imagen en formato JP2 (JPEG 2000) que suponía que sería la foto del DNI, así como la fecha de expiración, y el lugar de nacimiento. Básicamente, lo que viene a ser la información completa que suele estar en un DNI físico.

Esto me arrojaba bastante luz, ya que esto era sencillo de extraer, pero necesitaba saber cómo decodificar exactamente la información, y sobre todo, cómo verificar que dicha información era auténtica (que estaba firmada por el Ministerio del Interior).

Para esto, me descargué el APK de la aplicación MiDNI para Android junto a JADX, un decompilador de DEX a Java que me permitiría explorar la lógica de todo el proceso de decodificación y verificación del código QR.

Rápidamente pude identificar en la carpeta de recursos un archivo llamado "verification_certs.json", un nombre que me llamó la atención al instante. Este JSON parecía contener valores codificados en base64.

{
  "ESPN44849E9C5BB821426567187883DD76D6": "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",
  "ESPNC62C254CB38BAAE64CA065E8CD3C58E": "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",
  "ESPN4D393EEC9AD3289964D22FB9F744A884": "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"
}

Si descodificamos estos valores, efectivamente, nos encontramos con tres certificados, que seguramente sean los responsables de verificar que la información que contiene el código QR ha sido debidamente firmada por el Ministerio del Interior, y por lo tanto, que la información presentada es auténtica.

ID Nombre Común Tipo Válido Desde Válido Hasta Emisor Tipo de Clave
44849E9C APPDNIMOVILPRE Pre-producción 2023-11-29 2028-11-29 DIRECCION GENERAL DE LA POLICIA - AC DGP 004 ECDSA - Curva P-256
C62C254C APPDNIMOVIL Producción 2023-08-02 2028-08-02 DIRECCION GENERAL DE LA POLICIA - AC DGP 004 ECDSA - Curva P-256
4D393EEC APPDNIMOVIL Producción 2023-08-08 2028-08-08 DIRECCION GENERAL DE LA POLICIA - AC DGP 004 ECDSA - Curva P-256

Encontramos tres certificados diferentes, uno enfocado al entorno de pre-producción que tendrá el equipo de desarrollo de la Policía Nacional, y los otros dos que sirven en producción a los usuarios que utilizan la aplicación.

Con esta información, solo me faltaba conocer cómo decodificar el resto de datos, así como extraer la firma que utilizaría para contrastar la información contra el certificado público correspondiente, que debería ser uno de los dos que se utilizan en producción. Lo primero era probar con lo más obvio: el formato de datos TLV.

¿Qué es TLV?

TLV (Tag-Length-Value) es un formato de codificación de datos que organiza la información en grupos de tres elementos:

  1. Tag (etiqueta): Un número que identifica el tipo de dato.
  2. Length (longitud): Indica cuántos bytes ocupa el valor.
  3. Value (valor): Los datos en sí mismos.

Es como un sistema de "cajas etiquetadas" donde cada caja tiene:

  • Una etiqueta que indica qué contiene.
  • Una indicación del tamaño de su contenido.
  • El contenido propiamente dicho.

En el código QR del DNI, cada fragmento de información personal se almacena en formato TLV:

[TAG][LONGITUD][VALOR]

Por ejemplo, cuando analizamos el hexadecimal del QR, encontramos datos organizados de esta manera:

  • Tag 64: Número del DNI
  • Tag 66: Fecha de nacimiento
  • Tag 68: Nombre
  • Tag 70: Apellidos
  • Tag 72: Género
  • Tag 80: Fotografía (en formato JP2)
  • Tag 96: Dirección
  • Tag 112: Indicador de mayoría de edad

Podemos visualizar un ejemplo simplificado del formato TLV para los datos de un DNI:

64 (Tag: DNI) + 09 (Longitud: 9 bytes) + "12345678Z" (Valor)
68 (Tag: Nombre) + 05 (Longitud: 5 bytes) + "LAURA" (Valor)
70 (Tag: Apellidos) + 0D (Longitud: 13 bytes) + "GARCIA LOPEZ" (Valor)

El código de la aplicación recorre esta estructura, identifica cada tag, lee la longitud correspondiente y extrae exactamente ese número de bytes como valor. Como sabemos, la información de la que dispondremos dependerá del tipo de QR que se haya generado. Para ello, necesitaba en primer lugar obtener el tipo de QR que se había generado.

Los tipos de QR en el DNI electrónico (DocTypeEnum)

Analizando el código decompilado, específicamente la clase DocTypeEnum, podemos ver que el sistema contempla cuatro tipos diferentes de códigos QR:

public enum DocTypeEnum {
    AGE_VERIFICATION(9),       // Verificación solo de edad
    SIMPLE_VERIFICATION(7),    // Verificación simple
    COMPLETE_VERIFICATION(8),  // Verificación completa
    NONE_VERIFICATION(0);      // Sin verificación
    
    private final int value;
}

Cada tipo tiene un valor numérico asociado que lo identifica en los datos del código QR.

¿Para qué sirve cada tipo de QR?

Cada tipo de código QR está diseñado para un propósito específico, proporcionando diferentes niveles de información:

1. AGE_VERIFICATION (Verificación de edad)

Valor: 9
Propósito: Verificar únicamente si la persona es mayor de edad, sin mostrar datos personales adicionales.
Uso común: Establecimientos que necesitan verificar la mayoría de edad (bares, discotecas, estancos, etc.).

Este tipo de QR es el más respetuoso con la privacidad, ya que sólo confirma si el titular es mayor de edad sin revelar datos adicionales como nombre, fecha exacta de nacimiento, etc.

2. SIMPLE_VERIFICATION (Verificación simple)

Valor: 7
Propósito: Proporcionar datos básicos de identificación.
Uso común: Situaciones donde se requiere una verificación de identidad sin necesidad de todos los detalles (hoteles, comercios, etc.).

Incluye información básica como nombre y apellidos, número de DNI y fotografía, pero omite datos más sensibles o irrelevantes para una verificación rutinaria.

3. COMPLETE_VERIFICATION (Verificación completa)

Valor: 8
Propósito: Proporcionar todos los datos del DNI.
Uso común: Trámites oficiales que requieren verificación completa de identidad (notarías, bancos, administraciones públicas).

Este QR contiene todos los datos presentes en el DNI físico, incluyendo dirección, fecha de nacimiento completa, etc.

4. NONE_VERIFICATION (Sin verificación)

Valor: 0
Propósito: Formato de reserva o sin clasificación específica.
Uso común: Generalmente no se utiliza en DNIs emitidos; podría ser para casos especiales o futuros.

¿Cómo determina la aplicación el tipo de QR?

La aplicación MiDNI identifica el tipo de QR mediante un proceso que podemos ver en esta clase:

public final DocTypeEnum m17603a() {
    int m17612b = this.f12693b.m17612b();
    DocTypeEnum docTypeEnum = DocTypeEnum.AGE_VERIFICATION;
    if (m17612b == docTypeEnum.m17589h()) {
        return docTypeEnum;
    }
    DocTypeEnum docTypeEnum2 = DocTypeEnum.SIMPLE_VERIFICATION;
    if (m17612b == docTypeEnum2.m17589h()) {
        return docTypeEnum2;
    }
    DocTypeEnum docTypeEnum3 = DocTypeEnum.COMPLETE_VERIFICATION;
    return m17612b == docTypeEnum3.m17589h() ? docTypeEnum3 : DocTypeEnum.NONE_VERIFICATION;
}

El proceso es el siguiente:

  1. Se extrae un valor numérico de los datos del QR (this.f12693b.m17612b()).
  2. Se compara este valor con los valores definidos para cada tipo de DocType.
  3. Se devuelve el tipo correspondiente al valor encontrado.
  4. Si no coincide con ninguno, se devuelve NONE_VERIFICATION por defecto.

Ciclo de vida del procesamiento del tipo de QR

Cuando la aplicación escanea un código QR del DNI, sigue estos pasos para procesar su tipo:

1. Extracción del valor identificador

Durante el parseo inicial de los datos del QR (método m17608f() de la clase anterior), se extrae un valor específico que indica el tipo de documento:

b bVar8 = this.f12693b;
byte[] bArr9 = this.f12692a;
AbstractC4958j.m20427c(bArr9);
bVar8.m17617g(bArr9[i10 + 14] & 255);

Este valor se almacena en una propiedad interna (f12704g) de la estructura de datos.

2. Conversión y clasificación

Posteriormente, cuando se necesita saber qué tipo de QR se está procesando, se llama al método m17603a() que compara el valor extraído con los valores definidos en el enum DocTypeEnum.

3. Uso en la interfaz de usuario

Una vez determinado el tipo, la aplicación ajusta su comportamiento:

QRScanResultModel resultModel = new QRScanResultModel(
    m17540b(m17544f),  // El tipo de documento (conversión de DocTypeEnum a DNIType)
    m17546h(m17544f, 64),  // Número DNI
    // Otros campos...
);

Esto hay que tenerlo en cuenta a la hora de obtener la información, esperando no encontrar algunos datos dependiendo del tipo de QR que la persona haya elegido mostrar.

¿Qué es JPEG2000?

Me parece interesante también hablar sobre el formato elegido para la imagen del documento de identidad: JPEG2000 (JP2). Este es un formato de imagen avanzado diseñado para ofrecer mejor compresión y calidad que el JPEG tradicional, ya que puede almacenar la misma calidad de imagen en menos espacio y puede mostrar versiones de menor resolución sin necesidad de descargar toda la imagen. Además, este formato presenta un mejor comportamiento cuando hay datos dañados.

Cómo se usa JP2 en el DNI electrónico

En el código QR del DNI, la fotografía se almacena en formato JPEG2000 bajo el tag 80. Al escanear el QR, podemos identificar la sección JP2 de la fotografía por su cabecera característica:

00 00 00 14 66 74 79 70 6a 70 32 20 ...

Estos bytes iniciales (ftyp jp2) indican que comienza un archivo JPEG2000.

Verificación de la firma

El formato TLV me proporcionaba todos los datos personales que estaban embedidos en el código QR, no obstante, me faltaba un paso crucial: la verificación de autenticidad de la información usando los certificados.

Sabía a grandes rasgos cómo funcionaba la verificación, pero necesitaba obtener la lógica que me permitiría saber qué certificado público usar, así como extraer la firma que se ha utilizado para "sellar" el contenido.

1. Separación de datos y firma

La primera fase consiste en separar los datos del QR en dos componentes:

// La aplicación extrae los componentes del QR
byte[] datos = Arrays.copyOfRange(this.f12692a, 0, posicionFirma);
byte[] firma = this.f12695d; // La firma extraída del QR

El código QR contiene todos los datos personales (nombre, DNI, fecha de nacimiento, etc.) junto con una firma digital al final. La aplicación identifica dónde termina la información y dónde comienza la firma.

2. Obtención del certificado adecuado

Para verificar una firma, necesitamos la clave pública correcta:

// Seleccionamos el certificado de verificación adecuado
String identificadorCertificado = this.f12693b.m17613c() + this.f12693b.m17611a();
PublicKey clavePublica = hashMap.get(identificadorCertificado);

La aplicación contiene varios certificados (algunos para producción y otros para pre-producción). Basándose en el identificador encontrado en el QR, selecciona el certificado adecuado.

3. Detección del tipo de algoritmo

El sistema soporta diferentes algoritmos de firma:

// Determinamos qué algoritmo de firma se utilizó
if (hashMap.get(car) instanceof RSAPublicKey) {
    // Verificación con RSA
    signature = Signature.getInstance("SHA256withRSA");
} else if (hashMap.get(car) instanceof ECPublicKey) {
    // Verificación con ECDSA
    signature = Signature.getInstance("SHA256withECDSA");
}

Aunque el DNI actual utiliza principalmente ECDSA (firma digital de curva elíptica), el sistema está preparado para soportar también RSA si fuera necesario.

4. Inicialización del verificador

Se prepara el objeto que realizará la verificación matemática:

// Inicializamos el verificador con la clave pública
signature.initVerify(clavePublica);
// Le proporcionamos los datos originales
signature.update(datos);

Este paso carga la clave pública del certificado seleccionado y prepara los datos originales para la comparación.

5. Formato específico para ECDSA

Si se usa ECDSA, la firma requiere un tratamiento especial:

// Para ECDSA, necesitamos formatear la firma correctamente
BigInteger r = new BigInteger(1, Arrays.copyOfRange(firma, 0, firma.length / 2));
BigInteger s = new BigInteger(1, Arrays.copyOfRange(firma, firma.length / 2, firma.length));

ASN1EncodableVector vector = new ASN1EncodableVector();
vector.add(new ASN1Integer(r));
vector.add(new ASN1Integer(s));
byte[] formattedSignature = new DERSequence(vector).getEncoded();

Las firmas ECDSA constan de dos valores, R y S, que requieren un formato específico (DER) para la verificación. El código se encarga de preparar estos valores correctamente.

6. Verificación final

Finalmente, se realiza la verificación criptográfica:

// Realizamos la verificación matemática
boolean resultado = signature.verify(formattedSignature);

Este paso es donde la "magia" matemática ocurre: utilizando la clave pública, los datos originales y la firma proporcionada, el algoritmo determina si la firma es válida.

7. Resultado y acción

El resultado determina qué hace la aplicación:

if (!m17607e()) {
    throw new Exception("Seal not verified");
}

Si la verificación falla, la aplicación rechaza el QR como no válido. Si tiene éxito, procede a mostrar los datos del DNI.

El proceso completo de lectura

En resumidas cuentas, cuando la aplicación del DNI escanea un código QR, realiza estos pasos:

  1. Decodifica el QR: Convierte el patrón visual en datos binarios.
  2. Verifica la fecha y hora de creación: Si el código QR se ha generado en el último minuto es válido, de lo contrario, ha expirado y se muestra un error.
  3. Procesa la estructura TLV: Recorre los datos identificando cada tag y extrayendo su valor.
  4. Verifica la firma: Utiliza los certificados digitales para comprobar que los datos son auténticos y no han sido manipulados.
  5. Muestra la información: Presenta los datos del DNI, incluyendo la fotografía.

Con toda esta información, he construido una implementación en JavaScript que permite decodificar los datos del QR generado a través de la aplicación MiDNI. Esto permite que desarrolladores puedan integrar esta decodificación en cualquier aplicación donde su uso se crea necesario.

GitHub - dmquilez/midni-decoder: JavaScript implementation for decoding and verifying QR codes generated by MiDNI app
JavaScript implementation for decoding and verifying QR codes generated by MiDNI app - dmquilez/midni-decoder

El contenido presentado en este artículo sobre el funcionamiento de la app MiDNI se proporciona exclusivamente con fines educativos, de investigación y divulgación técnica. Este material está dirigido a profesionales y entusiastas de la tecnología interesados en comprender los mecanismos implementados en este nuevo sistema de identificación digital.

¿Cómo funciona técnicamente el DNI digital (MiDNI)?