En cualquier software complejo existen fallos, y cuando se trabaja en un proyecto grande uno se los encuentra a menudo. Los más memorables e interesantes son los que tienen una causa sencilla, pero se presentan de formas inusuales y a veces extrañas. Este error empezó con un único vídeo que provocó el bloqueo de un reproductor. En sí mismo no es un informe tan raro, sin embargo, los siguientes detalles destacaron definitivamente:
- Sólo ocurrió con un vídeo en concreto
- Sólo ocurría en Android (pero sólo en ciertas versiones de Android)
- Siempre se bloqueaba el reproductor exactamente a los 19 segundos de reproducción.
El vídeo se servía a dispositivos Android mediante HTTP Live Streaming, el estándar de Apple para la distribución de vídeo fragmentado basado en el formato de lista de reproducción m3u. Para la protección del contenido se utilizó AES-128 con relleno PKCS#7. Esto se conoce generalmente como cifrado HLS (o "HLSe" para abreviar). Otros formatos disponibles para el contenido, como MPEG-DASH, no presentaban el problema, ni tampoco HLS con FairPlay DRM (o incluso sin drm). El problema era aún más específico que "este vídeo", era "este vídeo, con HLSe"
Por suerte para las pruebas, este error era fácil de reproducir en la aplicación de demostración ExoPlayer de Google, y con una respetable selección de dispositivos de prueba a mano, no tardamos mucho en obtener códigos de error y trazas de pila de logcat para varias versiones principales de Android. Los errores específicos variaron dependiendo de la versión de Android en el dispositivo, pero todos compartían un tema común; en particular, que había algún problema descifrando los medios de comunicación en uno de los segmentos de vídeo. La excepción principal, y la más interesante "Causada por" más adelante en el seguimiento de la pila, eran todas variantes de estas dos (difiriendo en la redacción entre las versiones de Android probadas):
java.io.IOException: Error while finalizing cipher
at javax.crypto.CipherInputStream.fillBuffer(CipherInputStream.java:104)
at javax.crypto.CipherInputStream.read(CipherInputStream.java:155)
at com.google.android.exoplayer2.source.hls.Aes128DataSource.read(Aes128DataSource.java:96)
Caused by: javax.crypto.BadPaddingException: error:1e06b065:Cipher functions:EVP_DecryptFinal_ex:BAD_DECRYPT
at com.android.org.conscrypt.NativeCrypto.EVP_CipherFinal_ex(Native Method)
at com.android.org.conscrypt.OpenSSLCipher$EVP_CIPHER.doFinalInternal(OpenSSLCipher.java:568)
at com.android.org.conscrypt.OpenSSLCipher.engineDoFinal(OpenSSLCipher.java:385)
at javax.crypto.Cipher.doFinal(Cipher.java:1476)
Profundizar en el código es un poco complicado. El núcleo del código criptográfico está escrito en C y es bastante intimidante si no estás acostumbrado. Por suerte, ese "causado por" tiene un buen tipo legible, en particular javax.crypto.BadPaddingException
El problema está en el relleno. Según la especificación de Apple, el algoritmo de relleno que se utiliza en HLSe es PKCS#7, que se describe en RFC-5652 Sección 6.3:
6.3. Content-encryption Process
The content-encryption key for the desired content-encryption
algorithm is randomly generated. The data to be protected is padded
as described below, then the padded data is encrypted using the
content-encryption key. The encryption operation maps an arbitrary
string of octets (the data) to another string of octets (the
ciphertext) under control of a content-encryption key. The encrypted
data is included in the EnvelopedData encryptedContentInfo
encryptedContent OCTET STRING.
Some content-encryption algorithms assume the input length is a
multiple of k octets, where k is greater than one. For such
algorithms, the input shall be padded at the trailing end with
k-(lth mod k) octets all having value k-(lth mod k), where lth is
the length of the input. In other words, the input is padded at
the trailing end with one of the following strings:
01 -- if lth mod k = k-1
02 02 -- if lth mod k = k-2
.
.
.
k k ... k k -- if lth mod k = 0
The padding can be removed unambiguously since all input is padded,
including input values that are already a multiple of the block size,
and no padding string is a suffix of another. This padding method is
well defined if and only if k is less than 256.
Si eres como yo, esa descripción es un poco difícil de imaginar. He aquí algunos ejemplos (todos con un tamaño de bloque de 16 bytes):
88 7c 46 66 9a 2f a2 59 4d 1e
se rellenaría con06 06 06 06 06 06
63
se rellenaría con0f 0f 0f 0f 0f 0f 0f 0f 0f 0f 0f 0f 0f 0f 0f
f5 5c 6b 13 cf 54 f8 45 c1 ca 67 ec 50 20 12
se rellenaría con01
En pocas palabras, el valor de los bytes rellenados es el número de bytes que se rellenan, hasta el tamaño de bloque utilizado.
Armado con una comprensión de cómo debe funcionar el relleno, y un error que indica un relleno incorrecto, el siguiente paso fue comprobar el código que maneja el relleno para el vídeo. El servicio en cuestión está escrito en Go, que no tiene una implementación de PKCS#7 en su biblioteca estándar, por lo que el servicio tiene su propia implementación. Aquí está:
PKCS7Pad(in []byte) []byte {
padding := 16 - (len(in) % 16)
for i := 0; i < padding; i++ {
in = append(in, byte(padding))
}
return in
}
Corto y sencillo... y sin errores obvios. Incluso hay pruebas unitarias que parecen afirmar exactamente el comportamiento que esperamos. Así que quizás necesitemos más pistas. Llegados a este punto, nos desviamos para comparar lo que producía nuestro código con otras implementaciones. Por suerte, el cifrado AES-128 aplicado a los segmentos HLSe se aplica a todo el archivo del segmento, y no sólo a partes del contenido del archivo, como puede ocurrir con otros esquemas de protección de contenidos. La producción de segmentos cifrados alternativos se realizó fácilmente con OpenSSL, y pudimos utilizar nuestro empaquetador dinámico de medios para obtener una copia no protegida de los segmentos a cifrar. Con un segmento descargado tanto en claro, como encriptado AES-128, se generó un segmento encriptado alternativo usando el siguiente comando:
openssl aes-128-cbc -K $KEY -iv $IV -in clear.ts -out alt_protected.ts
Hacer esto reveló una nueva e interesante pieza de información, la salida del cifrado de OpenSSL era exactamente 16 bytes más grande que la salida de nuestra aplicación Go (es decir, un bloque). Quizás el problema no estaba en el relleno, sino en algún extraño caso límite en el que no se añadía un bloque final. Revisando de nuevo el código Go con esta nueva información en mente, la siguiente condicional empezó a parecer sospechosa:
// If this block does not match the AES block size we need to pad it out
if bytesRead != aes.BlockSize {
A primera vista, el algoritmo simplemente lee datos del búfer de entrada en trozos de longitud igual al tamaño del bloque. A continuación, comprueba cuántos bloques se han leído y, si es necesario, los amplía a 16 bytes. Por último, pasa el búfer de 16 bytes al cifrado y transmite la salida a un búfer de salida. Así que, siendo realistas, sólo esperamos que esta condicional se active en el último bloque, porque es el único que necesitará relleno. Después de algunas idas y venidas, algo empezó a hacerse obvio; hay un escenario que no se está manejando explícitamente: .... ¿qué pasa si el bloque final tiene la misma longitud que el tamaño del bloque? El código Go simplemente no hará nada, afirmando que no es necesario el relleno. Para estar seguros, volvamos a consultar la especificación:
the input shall be padded at the trailing end with k-(lth mod k) octets all having value k-(lth mod k), where lth is the length of the input.
Así que si k
es el tamaño de bloque, y lth
es la longitud de la entrada, entonces un bloque de longitud 16 necesitaría 16-(0 mod 16)
bytes de relleno. 16-(0 mod 16)
es igual a 16
... ¡Así que en realidad se supone que esto tiene un bloque entero de relleno al final, con el valor de cada byte relleno fijado en 16! ¡Por fin, una pista! ¡Nuestro código Go no cumple las especificaciones!
Saltando un poco hacia adelante a través de la prueba de un parche contra el mismo contenido que presentaba el problema, ¡finalmente conseguimos que funcione la reproducción más allá de la marca de 19 segundos en el vídeo! En última instancia, la solución consistió en reescribir el condicional defectuoso para rellenar el último bloque independientemente del número de bytes leídos. Esto resolvió el problema de una vez por todas.
En resumen, todas las características extrañas de este informe de error se derivan de la improbable realidad de que, de todos los contenidos que hemos probado con nuestro sistema, ésta es la primera vez que un vídeo (almacenado como mp4 fragmentado, multiplexado en segmentos de flujo de transporte) ha dado lugar a un segmento que tenía una longitud (en bytes) perfectamente divisible por 16. ¿Por qué 19 segundos en el vídeo? Bueno, los segmentos duran 10 segundos y era el tercer segmento con el relleno incorrecto. ¿Por qué el fallo sólo se produce en algunas versiones de Android, y aparentemente no en otros dispositivos? Bueno, parece que la mayoría de las implementaciones son resistentes a este error. Las versiones más antiguas de Android eran susceptibles, pero las versiones más modernas podían manejarlo.
Una vez respondidas todas las preguntas y corregido el error, no queda nada más que decir, salvo algunas lecciones aprendidas:
- Utilizar, siempre que sea posible, implementaciones de algoritmos estandarizados ya existentes y de eficacia probada.
- Si acabas implementando uno tú mismo, debes ser pedante con las especificaciones. Pasar por alto un caso extremo te costará caro.
- Incluso los bichos realmente divertidos y extraños pueden tener soluciones aburridas.