Firma v4, verificación y keystores
1. Qué es y por qué existe
Los esquemas v2 y v3 tienen una propiedad incómoda: para verificarlos hay que haber leído el
fichero entero. El digest se calcula sobre todos los bytes, así que el instalador no puede
decir «esto es auténtico» hasta que ha recibido el último. Con APK de cientos de megas —los
juegos del corpus rondan los 100 MB— eso significa esperar a la descarga completa antes de
poder ejecutar nada.
La instalación incremental —glosario— de Android 11 rompe
esa espera: la aplicación arranca con una parte del APK transferida y el resto se va trayendo
bajo demanda, verificando cada bloque en el momento en que se lee. Para eso hace falta un
digest que se pueda comprobar por trozos sin tener el resto, y eso es exactamente un árbol de
Merkle. El esquema v4 lo aporta.
La segunda mitad de este documento trata algo distinto pero inseparable: el orden en que el
sistema verifica. Con cinco esquemas conviviendo, «¿verifica este APK?» no tiene una
respuesta única, y las reglas de precedencia y de protección contra el downgrade son donde se
concentran los errores de implementación. La sección 5 las recorre en el orden real del código.
2. El fichero .idsig
2.1 Por qué va fuera del APK
Un árbol de Merkle sobre un fichero de 100 MB con bloques de 4 KiB ocupa del orden de 800 KB.
Meterlo dentro del APK Signing Block engordaría el APK para todos los dispositivos, y —lo
decisivo— el árbol es un digest del fichero completo, así que meterlo dentro cambiaría el
fichero del que se calculó. La solución es un fichero acompañante, <nombre>.apk.idsig, que
viaja al lado del APK y no dentro: adb install --incremental lo consume y el APK
instalado no lo contiene.
2.2 Estructura
De V4Signature.java. Todos los campos bytes van con prefijo uint32 de longitud, y todo
es little-endian.
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
version |
0x00 | 4 | uint32 |
CURRENT_VERSION = 2. «only version 2 is supported as of now» |
hashingInfo length |
0x04 | 4 | uint32 |
Longitud del bloque siguiente |
hashingInfo |
0x08 | var | — | Ver tabla siguiente |
signingInfos length |
— | 4 | uint32 |
|
signingInfos |
— | var | — | Ver tabla siguiente. Máximo MAX_SIGNING_INFOS_SIZE = 7168 |
verityTree length |
— | 4 | uint32 |
Opcional. Sin él, la firma es stripped |
verityTree |
— | var | — | El árbol de Merkle serializado |
hashingInfo:
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
hashAlgorithm |
0x00 | 4 | uint32 |
HASHING_ALGORITHM_SHA256 = 1. «only 1 == SHA256 supported» |
log2BlockSize |
0x04 | 1 | byte |
LOG2_BLOCK_SIZE_4096_BYTES = 12. «only 12 (block size 4096) supported now» |
salt length |
0x05 | 4 | uint32 |
0 en todo lo que produce apksigner. Máximo 32 |
salt |
0x09 | var | — | «used exactly as in fs-verity, 32 bytes max» |
rawRootHash length |
— | 4 | uint32 |
32 con SHA-256 |
rawRootHash |
— | var | — | «salted digest of the first Merkle tree page» |
signingInfo (el primero; detrás pueden ir bloques adicionales con su blockId):
| Campo | Tamaño | Tipo | Significado |
|---|---|---|---|
apkDigest |
var | bytes | Digest de contenido del APK — el mismo valor que el del bloque v3 o v2 |
certificate |
var | bytes | X.509 en DER |
additionalData |
var | bytes | «a free-form binary data blob» |
publicKey |
var | bytes | SubjectPublicKeyInfo en DER, debe corresponder al certificado |
signatureAlgorithmId |
4 | uint32 |
De la tabla de la sección 7 de APK Signing Block |
signature |
var | bytes | Firma sobre el signed data |
Lo que se firma no es el apkDigest a secas. V4Signature.getSignedData(fileSize, hashingInfo, signingInfo) construye un bloque que incluye el tamaño del fichero y todo el
hashingInfo —y por tanto la raíz del árbol— además del digest y el certificado. Firmar solo
la raíz permitiría reutilizar la firma con otro tamaño de fichero.
2.3 Un .idsig real, campo a campo
Generado en la sección 10.2 sobre una copia de org.fossify.calculator_1.4.0.apk de 7.176.562
bytes. Primeros 64 bytes:
00000000: 0200 0000 2d00 0000 0100 0000 0c00 0000 ....-...........
00000010: 0020 0000 0040 4cd0 b2fa 6a32 58fe 42ac . ...@L...j2X.B.
00000020: 6474 09b0 586f 9395 ae50 18a9 f1ab 5802 dt..Xo...P....X.
00000030: 4d06 0a60 f0cc 0500 0020 0000 00b7 628a M..`..... ....b.
Descompuesto:
0x00 02 00 00 00 version = 2
0x04 2d 00 00 00 hashingInfo = 45 bytes
0x08 01 00 00 00 hashAlgorithm = 1 (SHA-256)
0x0c 0c log2BlockSize = 12 → bloque de 4096
0x0d 00 00 00 00 salt = 0 bytes
0x11 20 00 00 00 rawRootHash = 32 bytes
0x15 40 4c d0 b2 … 0a 60 f0 404cd0b2fa6a3258fe42ac647409b0586f9395ae5018a9f1ab58024d060a60f0
0x35 cc 05 00 00 signingInfos = 1484 bytes
0x39 20 00 00 00 apkDigest = 32 bytes
0x3d b7 62 8a 52 … b7628a525424c06e29230c37fedf07f8b0e3fd19c2b3886ea23dee7b8ef10ee6
…
0x605 00 f0 00 00 verityTree = 61440 bytes
4 + 1 + 4 + 0 + 4 + 32 = 45: el hashingInfo cuadra al byte.
Y el apkDigest no es un número cualquiera. Al modificar un byte de ese mismo APK, el error
que devuelve apksigner sobre el bloque v3 es:
ERROR: APK Signature Scheme v3 signer #1: APK integrity check failed. CHUNKED_SHA256 digest
mismatch. Expected: <b7628a525424c06e29230c37fedf07f8b0e3fd19c2b3886ea23dee7b8ef10ee6>, …
El mismo valor. El apkDigest del .idsig es literalmente el digest de contenido del
bloque v3, copiado. Eso es lo que ata el .idsig a la firma que vive dentro del APK, y es
el mecanismo de la sección 4.
3. El árbol de Merkle y fs-verity
3.1 Cómo se construye
VerityTreeBuilder.java implementa el árbol de fs-verity con CHUNK_SIZE = 4096 y
JCA_ALGORITHM = "SHA-256". La documentación del kernel describe el mismo procedimiento:
«The file contents is divided into blocks, where the block size is configurable but is usually 4096 bytes. The end of the last block is zero-padded if needed. Each block is then hashed, producing the first level of hashes. Then, the hashes in this first level are grouped into
blocksize-byte blocks (zero-padding the ends as needed) and these blocks are hashed, producing the second level of hashes. This proceeds up the tree until only a single block remains.»
datos del APK, 4096 B por bloque
┌────┬────┬────┬────┬────┬────┬────┬─ … ─┬────┐
│ b0 │ b1 │ b2 │ b3 │ b4 │ b5 │ b6 │ │bn-1│ ← el ultimo, con relleno de ceros
└─┬──┴─┬──┴─┬──┴─┬──┴─┬──┴─┬──┴─┬──┴─────┴─┬──┘
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ SHA-256 de cada bloque
┌────┬────┬────┬────┬────┬────┬────┬─ … ─┬────┐
│ h0 │ h1 │ h2 │ h3 │ h4 │ h5 │ h6 │ │hn-1│ nivel 0: 32 B por hash
└────┴────┴────┴────┴────┴────┴────┴─────┴────┘
agrupados de nuevo en bloques de 4096 B (128 hashes por bloque)
▼
┌────┬────┬─ … ─┐
│ H0 │ H1 │ │ nivel 1
└────┴────┴─────┘
▼
┌────┐
│ R │ un solo bloque
└────┘
▼
raw root hash = SHA-256 de ese bloque
Como el APK cubierto es el mismo que cubre v2 —tramo 1 + Central Directory + EOCD con el
offset normalizado, ver sección 6.1 de APK Signing Block—, el árbol
hereda la misma exclusión del propio APK Signing Block, y por eso
generateVerityTreeRootHash empieza exigiendo:
if (beforeApkSigningBlock.size() % CHUNK_SIZE != 0) {
throw new IllegalStateException("APK Signing Block size not a multiple of " + CHUNK_SIZE …);
}
3.2 El tamaño del árbol, comprobado
calculateLevelOffset da el tamaño de cada nivel: cada uno se redondea al alza a múltiplo de
4096. Sobre el APK de 7.176.562 bytes de la sección 2.3:
| Nivel | Entradas | Bytes de hashes | Redondeado a 4096 |
|---|---|---|---|
| 0 (hashes de los datos) | ⌈7.176.562 / 4096⌉ = 1.753 | 1.753 × 32 = 56.096 | 57.344 |
| 1 | ⌈56.096 / 4096⌉ = 14 | 14 × 32 = 448 | 4.096 |
| Total | 61.440 |
Y el campo verityTree length del .idsig real vale exactamente 61.440. El fichero mide
62.985 bytes: 1.545 de cabecera y firma, más 61.440 de árbol. Es un buen test de humo para
cualquier implementación: el tamaño del árbol es determinista y se calcula del tamaño del
APK.
3.3 Qué comparte con fs-verity y qué no
La documentación de v4 es explícita: el árbol «follows the structure of the fs-verity hash tree
exactly (for example, zero-padding the salt and zero-padding the last block)». La razón es que
en el dispositivo el árbol lo reconstruye el kernel, no la aplicación: fs-verity lo
recalcula al instalar y lo guarda en el sistema de ficheros, y a partir de ahí cada lectura del
APK instalado se verifica contra él de forma transparente. El comentario de
VerityTreeBuilder lo dice: «The tree is currently stored only in memory and is never written
out. Nevertheless, it is the actual verity tree format on disk, and is supposed to be
re-generated on device».
Dos diferencias que conviene tener presentes:
- fs-verity firma un
fsverity_descriptor, no la raíz a secas. El kernel calcula el digest del fichero a partir de una estructura que incluye la raíz y los metadatos, «rather than the root hash alone, to resolve ambiguities in hashing». v4 hace lo análogo con sugetSignedData, pero no es la misma estructura byte a byte. - La sal. fs-verity la admite y la rellena con ceros «to the closest multiple of the input
size of the hash algorithm's compression function».
apksignerno la usa: el.idsiggenerado aquí declarasalt length = 0, y el comentario deV4SchemeSignerexplica por qué: «Salt has to stay empty for fs-verity compatibility».
4. v4 no se sostiene sola
La especificación lo dice en una línea: «A v4 signature requires a complementary v2 or v3
signature». No es una recomendación, es una precondición que apksig comprueba antes de
firmar:
BT=~/Library/Android/sdk/build-tools/37.0.0
$BT/apksigner sign --ks pruebas.jks --ks-key-alias pruebas --ks-pass pass:pruebas \
--v2-signing-enabled false --v3-signing-enabled false --v4-signing-enabled true \
--out solo_v4.apk copia.apk
Exception in thread "main" java.lang.IllegalStateException: APK Signature Scheme v4 signing
requires at least v2 or v3 signing to be enabled
at com.android.apksig.ApkSigner$Builder.build(ApkSigner.java:1923)
Por qué es así. El .idsig es un fichero suelto que cualquiera puede sustituir: no está
firmado por nada que viaje dentro del APK. Si v4 fuese autónoma, un atacante podría entregar
un APK cualquiera con un .idsig fabricado con su propia clave y el sistema no tendría con
qué contrastarlo. El anclaje es el apkDigest de la sección 2.3: el .idsig copia el digest
del bloque v2 o v3, así que verificar v4 obliga a que ese digest coincida con el que está
firmado dentro del APK por la clave del desarrollador. v4 aporta la granularidad por bloques;
la confianza sigue viniendo de v2/v3.
El código elige el digest en ese orden exacto — v3 si existe, v2 si no:
byte[] apkDigest = apkDigests.containsKey(VERSION_APK_SIGNATURE_SCHEME_V3)
? apkDigests.get(VERSION_APK_SIGNATURE_SCHEME_V3)
: apkDigests.get(VERSION_APK_SIGNATURE_SCHEME_V2);
5. El algoritmo completo de verificación
Reconstruido leyendo ApkVerifier.verify(), que es la implementación que Android usa y contra
la que se compara todo lo demás.
5.1 El flujo
entrada: APK, minSdk, maxSdk, [fichero .idsig]
│
┌──────────────────────▼───────────────────────────┐
│ localizar EOCD → Central Directory → Signing Blk │
└──────────────────────┬───────────────────────────┘
│
┌──────────────────────▼───────────────────────────┐
│ si maxSdk >= 24, en este orden: │
│ maxSdk >= 33 → bloque v3.1 0x1b93ad61 │
│ maxSdk >= 28 → bloque v3 0xf05368c0 │──┐
│ minSdk < 28 → bloque v2 0x7109871a │ │
│ o nada hallado antes │ │
│ .idsig dado → esquema v4 │ │
│ v3.1 presente sin v3 → error │ │
└──────────────────────┬───────────────────────────┘ │
│ │
┌──────────────────────▼───────────────────────────┐ │ bloque
│ si minSdk < 24 o nada hallado: │ │ presente
│ verificar v1 sobre META-INF/ │──┤ que no
└──────────────────────┬───────────────────────────┘ │ valida
│ │
┌──────────────────────▼───────────────────────────┐ │
│ si existe la entrada stamp-cert-sha256: │ │
│ verificar el source stamp 0x6dff800d │ │
├──────────────────────────────────────────────────┤ │
│ si v1 y v2 verificaron: mismo firmante │──┤
└──────────────────────┬───────────────────────────┘ │
▼ ▼
VERIFICA RECHAZO
5.2 Las reglas, en orden
1. De arriba hacia abajo, y el primero que se encuentra manda. El comentario del código: «Android N and newer attempts to verify APKs using the APK Signing Block, which can include v2 and/or v3 signatures. If none is found, it falls back to JAR signature verification. If the signature is found but does not verify, the APK is rejected.»
2. Un esquema superior presente obliga a que valide. Esta es la regla que más se malentiende. No es «prueba v3 y si falla prueba v2». Es «si hay bloque v3, tiene que validar; si no valida, se acabó». En el código, cada esquema termina con:
if (result.containsErrors()) {
return result;
}
No hay camino de vuelta. La caída a un esquema inferior solo ocurre por ausencia, nunca por fallo.
3. Los inferiores se saltan si un superior ya cubrió el rango. El bloque v2 solo se
verifica si minSdkVersion < 28 o si no se encontró nada; v1 solo si minSdkVersion < 24 o
si no se encontró nada. De ahí la salida v2: false sobre un APK que sí tiene bloque v2
(sección 8 de Esquemas de firma v1, v2 y v3).
4. La protección contra el downgrade cierra el hueco. Las reglas 1 a 3 dejarían un ataque obvio: quitar el bloque superior para que el sistema use el inferior, más débil o firmado con otra clave. Tres candados cierran esa vía, y los tres están firmados:
| Candado | Dónde vive | Qué declara |
|---|---|---|
X-Android-APK-Signed |
Sección principal del .SF de v1 |
Lista de esquemas superiores presentes |
0xbeeff00d STRIPPING_PROTECTION |
Atributo del bloque v2 | Número del esquema superior presente |
0x559f8b02 ROTATION_MIN_SDK_VERSION |
Atributo del bloque v3 | minSdkVersion que debe declarar el v3.1 |
Más la regla simétrica sin atributo: un bloque v3.1 sin bloque v3 es un error,
V31_BLOCK_FOUND_WITHOUT_V3_BLOCK. La demostración práctica del primer candado está en la
sección 9.3 de Esquemas de firma v1, v2 y v3.
5. Si v1 y v2 verifican los dos, los firmantes tienen que coincidir. Un APK con v1
firmado por A y v2 firmado por B se rechaza. Sin esa comprobación, un atacante con una clave
cualquiera podría añadir un bloque v2 propio a un APK firmado por otro y hacer que los
dispositivos modernos aceptasen su versión.
6. targetSandboxVersion > 1 exige APK Signing Block. Desde Android 8.0, un APK que
declare android:targetSandboxVersion mayor que 1 y no tenga ningún esquema v2+ se rechaza
(NO_SIG_FOR_TARGET_SANDBOX_VERSION).
5.3 Qué se verifica del Central Directory y del EOCD
No hay un paso separado: están dentro del digest. Los tramos 3 y 4 de la sección 6.1 de
APK Signing Block son el Central Directory completo y el EOCD,
así que cualquier cambio en un nombre, un offset, un tamaño o el número de entradas rompe el
digest igual que si se hubiera tocado un .dex.
La única pieza con tratamiento especial es el campo offset of start of central directory del
EOCD, sustituido por el tamaño del tramo 1 antes de digerir. Ese campo queda determinado,
no libre: su valor correcto se deduce de datos que sí están cubiertos.
Lo que no se verifica en esta capa es la coherencia interna del ZIP: que los
Local File Header concuerden con el Central Directory, que no haya nombres duplicados o
que no sobren bytes entre entradas. Eso es competencia del lector de ZIP
(libziparchive en el dispositivo), y su divergencia con el verificador es la raíz de las dos
vulnerabilidades históricas de la sección 12 de
Contenedor ZIP. Una firma válida no implica un
ZIP bien formado.
5.4 Un .idsig que no corresponde no es un error
Comportamiento observado con apksigner 0.9: al pasar un .idsig generado para otro APK,
la salida es
Verifies
…
Verified using v4 scheme (APK Signature Scheme v4): false
con código de salida 0. Lo mismo con un .idsig truncado. El fichero se descarta en silencio y
el veredicto global sigue siendo positivo, a diferencia de un digest v2/v3 que no cuadra, que
produce DOES NOT VERIFY.
⚠️ sin verificar: no se ha encontrado en la documentación oficial una afirmación de que ese sea
el comportamiento previsto ni de que el instalador de Android haga lo mismo; es lo que hace la
herramienta local sobre los ficheros construidos en la sección 10.
6. Cadenas de certificados: qué se verifica y qué no
Este punto se malinterpreta constantemente, así que conviene decirlo sin rodeos.
Android no usa una PKI para las firmas de aplicación. No hay lista de CA de confianza, no
se comprueba una cadena hasta una raíz, no se consultan CRL ni OCSP, y no se comprueba la
fecha de caducidad del certificado en el momento de instalar. El certificado de un APK es
casi siempre autofirmado: el subject y el issuer son la misma cadena, como se ve en el
.RSA de F-Droid de la sección 3.3 de
Esquemas de firma v1, v2 y v3.
Lo que sí se verifica:
| Se verifica | No se verifica |
|---|---|
| Que la firma es válida con la clave pública del bloque | Que el certificado lo emita una CA reconocida |
| Que la clave pública corresponde al certificado del firmante | Que la cadena llegue a una raíz de confianza |
| Que el digest del contenido coincide | Que el certificado no esté revocado |
Que la clave sea la misma que la de la instalación previa, o que esté acreditada por el lineage |
Que el certificado esté en vigor |
| Que los firmantes de v1 y v2 coincidan entre sí | Que el CN diga la verdad sobre quién es |
La identidad de una aplicación en Android es el hash de su certificado de firma. Nada más.
Un CN=Android, O=Google Inc. en un APK no acredita que lo haya hecho Google: cualquiera
puede generar ese dname con keytool en diez segundos. Lo que hace único al de Google es que
el hash de su certificado es el que el dispositivo ya tiene asociado a ese packageName.
De ahí se siguen dos consecuencias prácticas:
- Comparar por
CNes un error de análisis. Se compara por SHA-256 del certificado, que es lo que imprimeapksigner verify --print-certs. - Un certificado caducado sigue instalando. El requisito de validez hasta después del 22 de octubre de 2033 es una regla de Google Play, no de la plataforma: «If you plan to publish your apps on Google Play, the key you use to sign your app must have a validity period ending after 22 October 2033».
7. Leer un lineage de rotación
La estructura binaria del lineage, sus capacidades y su formato están en la sección 5.2 de
Esquemas de firma v1, v2 y v3. Lo que interesa aquí es cómo se lee
y qué decide.
Cómo se lee. Se recorre del primer nodo al último. Cada nodo lleva el certificado y las
capacidades que se le conceden a ese certificado ya rotado; la firma de cada nodo la hizo
la clave del nodo anterior, y el algoritmo con el que se hizo lo declara el nodo anterior en su
signed data. El verificador comprueba, para cada nodo a partir del segundo, que
sig(nodo_i) valida con la clave pública de nodo_{i-1}, y que el algoritmo declarado por el
padre coincide con el que dice el hijo.
Qué decide. Al instalar una actualización, el sistema tiene un certificado asociado al
packageName instalado. Si coincide con el firmante actual, no hay nada que decidir. Si no
coincide, busca el certificado instalado dentro del lineage: si está, y el firmante actual es
el último nodo, la sucesión es legítima y la actualización se acepta con las capacidades que el
lineage conceda al certificado antiguo.
apksigner lineage --print-certs lo enseña sin escribir código:
$BT/apksigner lineage --print-certs --in $SCR/keep_x/com.google.android.keep.apk
Signer #1 in lineage certificate DN: CN=Android, OU=Android, O=Google Inc., L=Mountain View, …
Signer #1 in lineage certificate SHA-256 digest: f0fd6c5b410f25cb25c3b53346c8972fae30f8ee7411df910480ad6b2d60db83
Has installed data capability: true
Has shared UID capability : true
Has permission capability : true
Has rollback capability : false
Has auth capability : true
Signer #2 in lineage certificate DN: CN=Android, OU=Android, O=Google Inc., L=Mountain View, …
Signer #2 in lineage certificate SHA-256 digest: 7ce83c1b71f3d572fed04c8d40c5cb10ff75e6d87d9df6fbd53f0468c2905053
…las mismas cinco capacidades, idénticas…
Y los dos SHA-256 se corresponden con los dos firmantes del verify de la sección 9.1 de
Esquemas de firma v1, v2 y v3: f0fd6c5b… es el firmante V3.0 con
rango [24, 32] y 7ce83c1b… el firmante V3.1 con rango [33, INT_MAX]. El lineage y los
rangos de SDK cuentan la misma historia desde dos sitios distintos del fichero, y que
coincidan es parte de lo que se verifica.
rollback: false en los dos nodos: Google no permite volver a la clave antigua. Es el valor
por defecto y el correcto.
8. El source stamp
Un source stamp acredita quién distribuyó el APK, de forma independiente a quién lo
firmó. Son dos piezas que tienen que existir a la vez:
- Una entrada del
ZIPllamadastamp-cert-sha256, en la raíz, con 32 bytes: el SHA-256 del certificado del sello. - Un par
0x6dff800den elAPK Signing Block, con el certificado completo, la firma y sus atributos.
La entrada del ZIP está cubierta por el digest de v2/v3, así que no se puede cambiar sin
romper la firma de distribución. Esa es la unión entre ambas piezas: el sello no puede
sustituirse por otro sin invalidar la firma del desarrollador.
Comprobado sobre el base.apk de Google Keep:
unzip -p $SCR/keep_x/com.google.android.keep.apk stamp-cert-sha256 | xxd
00000000: 3257 d599 a49d 2c96 1a47 1ca9 843f 59d3 2W....,..G...?Y.
00000010: 41a4 0588 4583 fc08 7df4 237b 733b bd6d A...E...}.#{s;.m
Y lo que informa apksigner:
Source Stamp Signer: certificate SHA-256 digest: 3257d599a49d2c961a471ca9843f59d341a405884583fc087df4237b733bbd6d
Source Stamp Signer: key size (bits): 4096
Source Stamp Timestamp: 1786040112
Byte a byte el mismo valor. El Source Stamp Timestamp es el atributo 0xe43c5946
(STAMP_TIME_ATTR_ID), «an 8-byte little-endian encoded long representing the epoch time in
seconds when the stamp block was signed».
En qué se diferencia de la firma de distribución:
| Firma v2/v3 | Source stamp | |
|---|---|---|
| Quién la pone | El desarrollador (o Play App Signing por él) | La cadena de compilación o distribución |
| Qué acredita | Continuidad de la clave y que los bytes no han cambiado | Origen: de dónde salió este binario |
| Qué pasa si falta | El APK no instala |
Nada, es opcional |
| Qué pasa si falla | El APK se rechaza |
apksigner informa y sigue |
| Presencia en el corpus | 100 % | 86,1 % |
En el corpus, el source stamp aparece en 617 de 717 APK, y prácticamente todos son los
splits producidos por bundletool, que lo añade por defecto. Un source stamp presente no es
señal de nada especial; su ausencia en un split que debería tenerlo sí lo es.
9. Keystores
La clave privada y su certificado viven en un almacén cifrado. Android admite dos formatos, y la diferencia importa a la hora de leerlos.
| JKS | PKCS#12 | |
|---|---|---|
| Origen | Propietario de Sun/Oracle | Estándar del sector, RFC 7292 |
| Extensión habitual | .jks, .keystore |
.p12, .pfx |
| Contraseña de almacén y de clave | Distintas: cada entrada tiene la suya | Una sola en la práctica |
| Estado | Obsoleto; keytool avisa al crear uno |
Por defecto desde Java 9 |
| En el ecosistema | Muy presente en keystores antiguos de firma | Lo que produce keytool hoy |
La distinción de la tercera fila es la que rompe herramientas: apksigner tiene
--ks-pass para el almacén y --key-pass para la clave, y con un PKCS#12 la segunda sobra.
Un lector que asuma que son la misma falla sobre los JKS antiguos, que son justo los que
siguen usándose para firmar aplicaciones publicadas hace años.
9.1 El debug keystore
Android Studio crea automáticamente un almacén de depuración con parámetros conocidos y públicos:
| Parámetro | Valor |
|---|---|
| Ruta (macOS y Linux) | ~/.android/debug.keystore |
| Ruta (Windows) | C:\Users\<usuario>\.android\debug.keystore |
| Contraseña del almacén | android |
| Alias | androiddebugkey |
| Contraseña de la clave | android |
La documentación de Google los publica: «The default password for the debug keystore is
android», con el alias androiddebugkey.
Por qué no debe usarse en producción, en orden de gravedad:
- La contraseña es pública y el fichero se filtra solo. Cualquiera que consiga el
debug.keystore—de un repositorio, de una imagen de CI— firma actualizaciones de esa aplicación. - Las tiendas lo rechazan. Literal de developer.android.com: «Because the debug certificate is created by the build tools and is insecure by design, most app stores (including the Google Play Store) do not accept apps signed with a debug certificate for publishing».
- No es la misma clave en dos máquinas, así que ni siquiera sirve para actualizar una aplicación desde otro ordenador.
⚠️ En esta máquina no existe ~/.android/debug.keystore —nunca se ha ejecutado Android
Studio—, así que la tabla de arriba procede de la documentación de Google y no se ha podido
comprobar en local. Tampoco se ha verificado el periodo de validez que le asigna la versión
actual de las herramientas.
10. Recetas
Herramientas: apksigner 0.9 y zipalign de build-tools 37.0.0, keytool de OpenJDK
21.0.10, xxd y python3 3.12. Ejecutado el 12 de agosto de 2026. Todo sobre copias en el
scratchpad; el corpus se lee y no se toca. Para el uso general de estas herramientas, ver
Firma y empaquetado.
10.1 Crear un keystore de prueba
SCR=/tmp/scratch/firma
keytool -genkeypair -v -keystore $SCR/pruebas.jks -storetype JKS -alias pruebas \
-keyalg RSA -keysize 2048 -validity 10000 \
-storepass pruebas -keypass pruebas \
-dname "CN=Ejemplo Test, OU=Doc, O=Ejemplo, L=Madrid, ST=Madrid, C=ES"
Generando par de claves RSA de 2.048 bits para certificado autofirmado (SHA384withRSA) con
una validez de 10.000 días
para: CN=Ejemplo Test, OU=Doc, O=Ejemplo, L=Madrid, ST=Madrid, C=ES
[Almacenando /tmp/scratch/firma/pruebas.jks]
Warning:
El almacén de claves JKS utiliza un formato propietario. Se recomienda migrar a PKCS12, que
es un formato estándar del sector que utiliza "keytool -importkeystore -srckeystore
pruebas.jks -destkeystore pruebas.jks -deststoretype pkcs12".
Con -storetype PKCS12 no aparece el aviso y -keypass sobra. Los 10.000 días de validez
llevan el certificado hasta 2053, muy por encima del umbral de Play. keytool sale en el
idioma del sistema; aquí, en español.
10.2 Firmar una copia y verificarla
CORPUS=~/corpus-apk
cp $CORPUS/org.fossify.calculator_1.4.0.apk $SCR/copia.apk
$BT/apksigner sign --ks $SCR/pruebas.jks --ks-key-alias pruebas \
--ks-pass pass:pruebas --key-pass pass:pruebas \
--v4-signing-enabled true --out $SCR/firmado.apk $SCR/copia.apk
ls -la $SCR/firmado.apk*
$BT/apksigner verify --verbose --print-certs $SCR/firmado.apk | head -12
-rw-r--r--@ 1 jj wheel 7176562 12 ago. 12:25 firmado.apk
-rw-r--r--@ 1 jj wheel 62985 12 ago. 12:25 firmado.apk.idsig
Verifies
Verified using v1 scheme (JAR signing): false
Verified using v2 scheme (APK Signature Scheme v2): true
Verified using v3 scheme (APK Signature Scheme v3): true
…v3.1 y v3.2: false…
Verified using v4 scheme (APK Signature Scheme v4): false
Verified for SourceStamp: false
Number of signers: 1
V3.0 Signer: certificate DN: CN=Ejemplo Test, OU=Doc, O=Ejemplo, L=Madrid, ST=Madrid, C=ES
V3.0 Signer: certificate SHA-256 digest: d14efe3056cabee10e58c84110c4d0d1df324544bc57645d3de8fc53fa895caa
v4: false con el .idsig recién generado al lado. No es un fallo: apksigner no busca
el fichero por convención de nombre, hay que dárselo:
$BT/apksigner verify --verbose --v4-signature-file $SCR/firmado.apk.idsig $SCR/firmado.apk \
| sed -n '1p;7p'
Verifies
Verified using v4 scheme (APK Signature Scheme v4): true
Es el error de diagnóstico más frecuente con v4: concluir que la firma no se generó cuando lo que falta es el argumento.
10.3 Leer el .idsig
El volcado hexadecimal está descompuesto campo a campo en la sección 2.3. El recorrido programático son quince líneas:
python3 - <<'PY'
import struct
b = open("/tmp/scratch/firma/firmado.apk.idsig", "rb").read()
p = 0
ver, = struct.unpack_from("<I", b, p); p += 4
hl, = struct.unpack_from("<I", b, p); p += 4
hi = b[p:p+hl]; p += hl
sl, = struct.unpack_from("<I", b, p); p += 4 + sl
tl, = struct.unpack_from("<I", b, p); p += 4
salt, = struct.unpack_from("<I", hi, 5)
rl, = struct.unpack_from("<I", hi, 9 + salt)
print("version", ver, "hashingInfo", hl, "signingInfos", sl, "verityTree", tl)
print("hashAlgorithm", struct.unpack_from("<I", hi, 0)[0], "log2BlockSize", hi[4],
"bloque", 2**hi[4], "salt", salt)
print("rawRootHash", hi[13+salt:13+salt+rl].hex())
print("offset del arbol", p, "bytes restantes", len(b) - p)
PY
version 2 hashingInfo 45 signingInfos 1484 verityTree 61440
hashAlgorithm 1 log2BlockSize 12 bloque 4096 salt 0
rawRootHash 404cd0b2fa6a3258fe42ac647409b0586f9395ae5018a9f1ab58024d060a60f0
offset del arbol 1545 bytes restantes 61440
Los 61.440 bytes del árbol son los que predice el cálculo de la sección 3.2.
10.4 Comprobar que la firma detecta un cambio de un solo byte
cp $SCR/firmado.apk $SCR/tocado.apk
python3 -c "
b = bytearray(open('/tmp/scratch/firma/tocado.apk','rb').read())
b[1000000] ^= 0x01
open('/tmp/scratch/firma/tocado.apk','wb').write(b)"
$BT/apksigner verify --verbose $SCR/tocado.apk
DOES NOT VERIFY
ERROR: APK Signature Scheme v3 signer #1: APK integrity check failed. CHUNKED_SHA256 digest
mismatch. Expected: <b7628a525424c06e29230c37fedf07f8b0e3fd19c2b3886ea23dee7b8ef10ee6>,
actual: <29c86f47c4b775bea6692b2a93da805698c39b7c4c776a9fc845b64edeecfe67>
Un bit invertido en un byte perdido en medio del fichero. El Expected es el mismo valor que
el apkDigest del .idsig de la sección 10.3, que es el anclaje de la sección 4.
Fuentes
- APK signature scheme v4 — Android Open Source Project — https://source.android.com/docs/security/features/apksigning/v4
Consultado el 12 de agosto de 2026. De aquí salen la descripción del esquema, la estructura
del
.idsigde la sección 2.2 con sus citas literales sobre versión, algoritmo, tamaño de bloque y sal, la exigencia «A v4 signature requires a complementary v2 or v3 signature» de la sección 4 y la relación exacta con fs-verity de la sección 3.3. - AOSP —
tools/apksig/.../internal/apk/v4/V4Signature.java— https://android.googlesource.com/platform/tools/apksig/+/refs/heads/main/src/main/java/com/android/apksig/internal/apk/v4/V4Signature.java Consultado el 12 de agosto de 2026. De aquí salen las tablas de campos de la sección 2.2:CURRENT_VERSION,HASHING_ALGORITHM_SHA256,LOG2_BLOCK_SIZE_4096_BYTES,MAX_SIGNING_INFOS_SIZEy el orden exacto de los campos deHashingInfoySigningInfo. - AOSP —
tools/apksig/.../internal/apk/v4/V4SchemeSigner.java— https://android.googlesource.com/platform/tools/apksig/+/refs/heads/main/src/main/java/com/android/apksig/internal/apk/v4/V4SchemeSigner.java Consultado el 12 de agosto de 2026. De aquí salen el comentario sobre el árbol como campo opcional con prefijo de longitud, la elección delapkDigestentre v3 y v2 de la sección 4 y el comentario «Salt has to stay empty for fs-verity compatibility». - AOSP —
tools/apksig/.../internal/apk/v4/V4SchemeVerifier.java— https://android.googlesource.com/platform/tools/apksig/+/refs/heads/main/src/main/java/com/android/apksig/internal/apk/v4/V4SchemeVerifier.java Consultado el 12 de agosto de 2026. De aquí salen los cuatro objetivos del verificador de v4 citados en la sección 5.4. - AOSP —
tools/apksig/.../internal/util/VerityTreeBuilder.java— https://android.googlesource.com/platform/tools/apksig/+/refs/heads/main/src/main/java/com/android/apksig/internal/util/VerityTreeBuilder.java Consultado el 12 de agosto de 2026. De aquí salenCHUNK_SIZE = 4096, el algoritmocalculateLevelOffsetcon su redondeo a 4096 de la sección 3.2, la exigencia de alineación degenerateVerityTreeRootHashy el comentario sobre la regeneración del árbol en el dispositivo. - AOSP —
tools/apksig/.../ApkVerifier.java— https://android.googlesource.com/platform/tools/apksig/+/refs/heads/main/src/main/java/com/android/apksig/ApkVerifier.java Consultado el 12 de agosto de 2026. De aquí sale íntegro el flujo de la sección 5.1, con las condiciones exactas de cada esquema, los comentarios citados en 5.2, la comprobación de coincidencia entre firmantes v1 y v2, la detargetSandboxVersiony la búsqueda de la entradastamp-cert-sha256. - AOSP —
tools/apksig/.../internal/apk/stamp/SourceStampConstants.java— https://android.googlesource.com/platform/tools/apksig/+/refs/heads/main/src/main/java/com/android/apksig/internal/apk/stamp/SourceStampConstants.java Consultado el 12 de agosto de 2026. De aquí salen el nombre de la entradastamp-cert-sha256, el identificador0x6dff800dy la descripción del atributo de marca de tiempo0xe43c5946de la sección 8. - fs-verity — documentación del kernel de Linux — https://docs.kernel.org/filesystems/fsverity.html
Consultado el 12 de agosto de 2026. De aquí salen la definición de fs-verity, la
construcción del árbol de Merkle citada en la sección 3.1, el relleno con ceros del último
bloque, el tratamiento de la sal y la observación de que el digest se calcula sobre el
fsverity_descriptory no sobre la raíz sola. - apksigner — Android Studio — https://developer.android.com/tools/apksigner
Consultado el 12 de agosto de 2026. De aquí salen las opciones
--v4-signing-enabled,--v4-signature-file,--ks-passy--key-passusadas en la sección 10. - Sign your app — Android Studio — https://developer.android.com/studio/publish/app-signing Consultado el 12 de agosto de 2026. De aquí salen la ruta del debug keystore, la cita sobre por qué las tiendas no lo aceptan, el requisito de validez hasta después del 22 de octubre de 2033 y la recomendación de 25 años de la sección 9.
- Client authentication — Google Play services — https://developers.google.com/android/guides/client-auth
Consultado el 12 de agosto de 2026. De aquí salen la contraseña
androidy el aliasandroiddebugkeydel debug keystore de la sección 9.1. Se usa esta fuente porque la página de firma de aplicaciones no publica la contraseña. - Corpus de verificación —
~/corpus-apkMedido el 12 de agosto de 2026. De aquí salen elsource stampde Google Keep de la sección 8, ellineagede la sección 7, el porcentaje desource stampsobre 717APKy elAPKde origen de todas las salidas de la sección 10.