Skip to content

Actualizaciones Android firmadas

Arveil puede buscar actualizaciones, descargar un APK verificado y pedir a Android que lo instale encima de la app existente. La identidad y las conversaciones se quedan en el almacenamiento de la app. Nunca desinstales ni borres el almacenamiento para actualizar. Un cliente anterior sin esta pantalla necesita una instalación manual por encima de la app existente para incorporarla.

Las actualizaciones son independientes del realm. Quien distribuye la app elige al compilarla un manifiesto HTTPS y una clave pública Ed25519. Una compilación normal desde el código no tiene manifiesto y no contacta con ningún servicio operado por el proyecto. En macOS la app consulta el mismo manifiesto firmado y, si hay una versión más reciente, abre su descarga: la persona sustituye la app o ejecuta brew upgrade --cask arveil (véase En macOS). No están implementados Sparkle ni la rotación automática de la clave de actualización.

English: Signed Android updates.

Experiencia de uso y privacidad

Abre Ajustes → Actualizaciones, o el icono de actualización en la pantalla con el perfil cerrado. La búsqueda es manual por defecto. La comprobación opcional en primer plano se ejecuta como máximo una vez al día, contando los intentos fallidos y los reinicios. Muestra un aviso dentro de la app; no hay servicio push, consultas periódicas en segundo plano ni notificaciones con la app cerrada. Activar la opción no instala nada.

Una comprobación pide el manifiesto completo sin versión instalada, ID de perfil, cookie ni un User-Agent identificativo. El alojamiento o la CDN del manifiesto siguen viendo la IP y la hora. Descargar una actualización contacta además con el alojamiento del APK. Ambas acciones funcionan sin abrir un perfil ni conectar con un realm.

La app verifica el anuncio antes de mostrar sus notas de la versión. El tamaño y el SHA-256 de la descarga deben coincidir. Instalar actualización puede pedir primero el permiso de Android para que esta app instale paquetes (en Android 7, el ajuste global Orígenes desconocidos, desactivado de fábrica en los teléfonos); vuelve a Arveil y pulsa Instalar actualización otra vez. Antes de crear una sesión de PackageInstaller, la app comprueba el ID del paquete, que el número de compilación sea mayor y el certificado de firma actual, y vuelve a calcular el hash de los bytes mientras los copia a la sesión. En Android 12 o posterior, la sesión exige explícitamente una acción del usuario. Android hace la verificación final del APK y pide confirmación al usuario. No se ofrece como alternativa desinstalar, volver a una versión anterior ni borrar los datos. Un anuncio caducado debe renovarse antes de instalar.

Una oferta que caduca, o que supera un anuncio más reciente, desaparece de la pantalla. Una comprobación que falla, por falta de conexión o porque el servicio respondió mal, conserva una oferta que sigue siendo válida, con su descarga. Si Android no puede recibir el paquete, por ejemplo por falta de espacio, la descarga se conserva para otro intento; un paquete que no es el anunciado se borra. Algunas versiones de Android no responden cuando se descarta la confirmación: al volver a Arveil, el intento figura como cancelado al cabo de un momento, y Instalar actualización vuelve a empezar. El enlace a las notas de la versión se abre en el navegador, que contacta con ese sitio.

Solo los builds con canal de actualizaciones piden el permiso de Android para instalar paquetes. Ajustes → Diagnóstico indica updates: seguido de none, del canal, o de invalid cuando el build lleva una configuración de actualizaciones que la app rechazó; ese build se comporta como uno sin actualizaciones.

En macOS

La app del Mac tiene la misma pantalla Ajustes → Actualizaciones, la misma comprobación diaria opcional y la misma verificación: firma, canal, secuencia y caducidad. Ofrece la entrada macos-arm64 del anuncio cuando su compilación es mayor que la de la propia app (CFBundleVersion) y el Mac cumple minimum_os. Descargar en el navegador abre la url de esa entrada, el ZIP de la release de GitHub; la app nunca descarga, descomprime ni sustituye nada por su cuenta. La persona cierra Arveil y sustituye la app en Aplicaciones, o ejecuta brew upgrade --cask arveil si la instaló con Homebrew. En los dos casos el perfil se queda en el contenedor aislado de la app. Un anuncio sin entrada de macOS no ofrece nada en el Mac, y Android ignora la entrada de macOS.

El paquete del Mac lleva la configuración de actualizaciones igual que el APK: compílalo con --update-config y firma el anuncio también con --macos-package y --macos-asset-url. Los dos paquetes tienen que ser de la misma versión y compilación.

Configurar una distribución

Usa el empaquetado del cliente y conserva la clave de release de Android existente. Crea una sola vez, con OpenSSL 3, una clave de actualización distinta que se guarde sin conexión:

python3 scripts/client_updates.py init-key --key .local/update-signing/update.pem

Haz una copia cifrada, separada de la clave del APK. Nunca debe estar en la web, el relay ni la CI. El comando se niega a sobrescribirla, avisando de que la clave ya existe, y solo imprime la clave pública.

El comando pide dos veces en el terminal una frase de contraseña de al menos 12 caracteres y guarda la clave cifrada con ella (PKCS#8, AES-256 y PBKDF2-HMAC-SHA256 con 600 000 iteraciones); al firmar vuelve a pedirla. Ni la frase de contraseña ni la clave pasan por argumentos de la línea de órdenes ni por variables de entorno. Genera una frase larga y aleatoria y guárdala en un gestor de contraseñas, nunca junto a la clave. Perder la frase de contraseña es perder la clave: la única salida es rotar la clave de actualización (véase más abajo), lo que exige una build nueva firmada con la clave de Android. Para usos automatizados, --passphrase-fd N lee la frase de contraseña de la primera línea del descriptor de archivo heredado N en lugar del terminal; por ejemplo, a través de una tubería desde la herramienta de línea de órdenes de un gestor de contraseñas. Sin terminal ni esa opción, la herramienta se detiene en vez de leer una frase de contraseña que se vería en pantalla.

Una clave creada antes de que existiera este cifrado es un PEM sin cifrar. La firma todavía la acepta, con un aviso. Cífrala una vez; el comando comprueba que la clave pública no cambia y la imprime. Después sustituye el archivo y destruye la clave sin cifrar y todas sus copias sin cifrar:

python3 scripts/client_updates.py encrypt-key \
  --key .local/update-signing/update.pem \
  --output .local/update-signing/update-encrypted.pem
mv .local/update-signing/update-encrypted.pem .local/update-signing/update.pem

Guarda el siguiente archivo en .local/distribution.json, con modo 0600:

{
  "ARVEIL_UPDATE_URL": "https://project.example.org/updates/clients-beta.json",
  "ARVEIL_UPDATE_PUBLIC_KEY": "BASE64_32_BYTE_PUBLIC_KEY",
  "ARVEIL_UPDATE_CHANNEL": "beta"
}

Sustituye el marcador de la clave pública por el valor impreso. Solo se aceptan estos tres campos. Usa stable o beta; cada canal tiene su propio manifiesto y su propia secuencia. Nunca añadas a este archivo el nombre de host de un realm, un bootstrap, una invitación, una clave privada ni un token del túnel. La URL, la clave pública y el canal se incrustan en la app y constan en su BUILD.json público; la ruta local de este archivo, no.

python3 scripts/package_clients.py build android \
  --signing-config .local/signing/android-signing.json \
  --update-config .local/distribution.json --build-number 18

El número de compilación es un ejemplo: elige siempre uno mayor que el de todas las compilaciones ya distribuidas con esa clave de firma de Android. El asistente de empaquetado comprueba que el versionCode del APK coincide con este número de compilación, que BUILD.json registra y el anuncio transmite, y que el APK pide el permiso de Android para instalar paquetes solo cuando se compila con --update-config. Compila el paquete de macOS con el mismo --update-config y número de compilación, para que la app del Mac pueda avisar de la versión. Haz antes commit del código; los candidatos con cambios sin confirmar pueden probarse en local, pero el comando de firma no puede anunciarlos. El manifiesto es una opción de distribución, no una dependencia del autoalojamiento.

Anunciar una versión

Prepara la release inmutable clients-v* de GitHub y verifica sus artefactos como se describe en la guía de releases. Escribe unas notas de la versión breves, en texto plano, en un archivo privado. Firma un anuncio nuevo en local:

python3 scripts/client_updates.py sign \
  --key .local/update-signing/update.pem \
  --config .local/distribution.json \
  --package dist/clients/0.1.0+18/android \
  --sequence 1 --valid-days 30 \
  --notes .local/release-notes.txt \
  --asset-url https://github.com/example/arveil/releases/download/clients-v0.1.0-beta.1/arveil-0.1.0-18-android-arm64.apk \
  --macos-package dist/clients/0.1.0+18/macos \
  --macos-asset-url https://github.com/example/arveil/releases/download/clients-v0.1.0-beta.1/arveil-0.1.0-18-macos-arm64.zip \
  --notes-url https://github.com/example/arveil/releases/tag/clients-v0.1.0-beta.1 \
  --output .local/releases/clients-beta-1.json

Cualquier cambio en la carga útil, incluida una ampliación de la caducidad para el mismo APK, necesita una secuencia superior. La herramienta lleva ese registro por sí misma: sequences.json, junto a la clave (aquí en .local/update-signing/, que Git ignora), guarda por canal cada secuencia firmada con su versión, su compilación, su caducidad y el SHA-256 del archivo firmado. Pasa --sequence solo en el primer anuncio de un canal: 1 si el canal es nuevo o, si ya se firmaron anuncios antes de que existiera el registro, uno más que el último publicado. Después, omítelo: la herramienta usa el número siguiente y lo imprime. Un --sequence explícito debe ser mayor que el último registrado. Si falta el registro, o un canal no tiene entradas, no hay ningún anuncio anterior. Un registro con formato no válido detiene la firma y nunca se restablece: recupéralo de una copia de seguridad o corrígelo a mano. La entrada se escribe de forma atómica, con modo 0600, solo después de verificar la firma y antes del archivo de salida, así que un fallo al escribir puede saltarse un número, pero nunca reutilizarlo; los clientes aceptan huecos. Haz copia de seguridad del registro junto con la clave.

La herramienta comprueba los hashes del paquete, los metadatos de compilación limpia, que la distribución y la clave concuerden y que la URL del recurso de GitHub sea inmutable; después verifica su propia firma con OpenSSL. Solo escribe archivos nuevos, en modo exclusivo; nunca publica ni sobrescribe uno existente.

Publica primero el APK verificado, adjunta el anuncio firmado con su nombre de archivo único por secuencia y sirve de forma atómica esos mismos bytes en la URL fija del manifiesto. No redirijas el manifiesto ni pongas delante un inicio de sesión o un desafío de navegador. El cliente pide Accept-Encoding: identity y rechaza las respuestas comprimidas, para que sus límites y hashes se apliquen a los bytes exactos. Usa Content-Type: application/json, sin compresión del contenido, y Cache-Control: no-cache o una vida de caché corta; al publicar, purga el manifiesto antiguo que siga en caché. Las descargas pueden seguir como máximo cinco redirecciones HTTPS, porque los recursos de GitHub se sirven desde un host de almacenamiento. La app confía en las autoridades de certificación del sistema y además en ISRG Root X1: GitHub sirve esos recursos con Let's Encrypt, y Android 7.0 no incluye esa raíz. Nunca uses releases/latest ni sustituyas un APK ya publicado. Después de publicar, compara el manifiesto público con los bytes firmados en local. Sirve el manifiesto por separado de cualquier realm personal.

Si Browser Integrity Check de Cloudflare rechaza el perfil de cabeceras del actualizador, aplica la excepción de ruta exacta y su procedimiento de reactivación. Guarda el ID real de la regla, su alcance, la aprobación y el historial de reversiones en notas privadas de operación. Volver a activar BIC puede bloquear de nuevo las comprobaciones de actualizaciones; la app debe seguir rechazando las firmas no válidas en lugar de saltarse la verificación para recuperar el acceso.

La caducidad por defecto es de 30 días (máximo 90). Renueva el anuncio con una secuencia nueva antes de que caduque, aunque no haya un APK nuevo. La caducidad impide ofrecer actualizaciones a partir de un anuncio obsoleto; no desactiva la mensajería. Un alojamiento todavía puede ocultar un anuncio más reciente mientras siga siendo válido uno anterior firmado, y la manipulación del reloj del dispositivo queda fuera de la protección contra retrocesos.

Formato de transmisión y estado local

El sobre es un JSON con schema: 1, payload en base64 y signature en base64. Firma con Ed25519 los bytes UTF-8 arveil-client-updates-v1\n seguidos de los bytes exactos de la carga útil decodificada. La verificación no vuelve a serializar el JSON. La carga útil contiene:

{
  "schema": 1,
  "channel": "beta",
  "sequence": 1,
  "expires": "2030-01-01T00:00:00Z",
  "platforms": {
    "android-arm64": {
      "version": "0.1.0",
      "build": 18,
      "minimum_sdk": 24,
      "application_id": "io.github.ulzuhan.arveil",
      "url": "https://github.com/example/arveil/releases/download/clients-v0.1.0-beta.1/app.apk",
      "size": 123,
      "sha256": "64-lowercase-hex-characters",
      "notes": "Plain-text release notes.",
      "notes_url": "https://example.org/releases/18"
    },
    "macos-arm64": {
      "version": "0.1.0",
      "build": 18,
      "minimum_os": "12.0",
      "url": "https://github.com/example/arveil/releases/download/clients-v0.1.0-beta.1/app.zip",
      "size": 123,
      "sha256": "64-lowercase-hex-characters",
      "notes": "Plain-text release notes.",
      "notes_url": "https://example.org/releases/18"
    }
  }
}

macos-arm64 es opcional; la entrada de Android siempre está, así que los clientes Android anteriores siguen leyendo el manifiesto. Una entrada de macOS mal formada rechaza el anuncio entero, igual que una de Android.

El manifiesto está limitado a 64 KiB y el APK a 512 MiB. Las notas de la versión son texto plano de como mucho 8000 code points Unicode; el firmador y la app las cuentan igual, y clients/flutter/test/fixtures/update-manifest-vectors.json mantiene sus reglas alineadas. Una descarga falla si se detiene 30 segundos o si dura más de 10 minutos más un segundo por cada 16 KiB del paquete; las comprobaciones de tamaño durante la transmisión también la acotan. Las descargas no válidas o incompletas se eliminan, y un paquete descargado se elimina la siguiente vez que arranca la app, así que no queda tras instalarlo. La app vuelve a calcular el hash de los bytes mientras los copia a la sesión de instalación.

updates.json, en el directorio de soporte de la aplicación, guarda si activaste la comprobación, el último intento y, para cada clave de actualización y canal, la secuencia más alta aceptada junto con el resumen (digest) de su carga útil. Una build con otra clave u otro canal empieza su propio historial en cero; los demás conservan su protección. No contiene información del perfil. La sustitución atómica de ese archivo debe completarse antes de ofrecer un anuncio. Se permite repetir exactamente el mismo anuncio; no se permite una secuencia menor ni una carga útil distinta con la misma secuencia. Si falla la lectura o la escritura, se bloquea en vez de restablecer esa protección. No borres los datos de la app para recuperarlo: también borra el perfil cifrado. Borrar el almacenamiento de la app o un atacante local con privilegios puede restablecer este estado.

Cambiar una instalación de canal no necesita nada más: el canal nuevo conserva su propio historial. Rotar la clave de actualización exige publicar una build con la clave pública nueva, que la gente instala a mano una vez, como explica la ADR-010; nunca restablezcas el estado de actualización ni pidas a la gente que reinstale desde cero. Perder la clave de actualización o su frase de contraseña solo deja esta rotación, y esa build debe firmarse con la clave de Android existente. La comprobación actual del certificado Android exige a propósito los mismos firmantes actuales y no implementa la migración por linaje de la clave de firma del APK.

Verificación

flutter test test/updates_test.dart test/update_transport_test.dart cubre las firmas (incluido un vector OpenSSL independiente), la caducidad, la reutilización de secuencias y los retrocesos, las comprobaciones desactivadas, la programación diaria, la manipulación del paquete y las redirecciones. python3 -m unittest discover -s scripts -p 'test_client_updates.py' prueba la frontera de la firma sin conexión, incluidos el registro de secuencias y las claves cifradas; test_package_clients.py comprueba el versionCode del APK y el permiso del instalador. En Android, app:testDebugUnitTest comprueba los bytes exactos de la sesión, el ID de aplicación, la versión y la política de certificados.

Para el instalador real del sistema, usa el punto de entrada privado integration_test/update_installer_acceptance.dart en un emulador desechable. Compila un APK de depuración con ARVEIL_TEST_UPDATER=before y después otro con ARVEIL_TEST_UPDATER=after y un número de compilación mayor, ambos firmados con la misma clave de prueba. El primero crea un perfil nativo cifrado y conserva su clave en Android Keystore. Pon el segundo APK en cache/updates/update.apk, dentro del almacenamiento privado de la app, y su build, size y sha256 en cache/updates/acceptance.json. Concede el permiso de instalación para esta app, pulsa Install test update y verifica la confirmación del sistema. Después de la instalación, vuelve a abrir la app y exige ARVEIL_TEST_UPDATER_OK:profile:after. Prueba también la denegación, la cancelación, un certificado erróneo y la manipulación; en todos los casos debe conservarse la app instalada. No uses adb install -r para el segundo APK en esta prueba: se trata de ejercitar la ruta propia de PackageInstaller de la app. Nunca distribuyas ninguno de los dos APK de aceptación.

Para el flujo completo, usa integration_test/update_flow_acceptance.dart. Ejecuta la app real con el controlador, el transporte, la comprobación de firma en Rust y el instalador reales; la única diferencia es una raíz de confianza más, la autoridad desechable de un canal de prueba HTTPS local, que se pasa en ARVEIL_TEST_UPDATE_CA. Compila un APK before y otro after con la URL de ese canal y una clave de actualización desechable, firma con ella un anuncio para el segundo, sirve ambos desde el canal de prueba y usa Ajustes → Actualizaciones en el primero: buscar, descargar, instalar y confirmar. El segundo debe indicar ARVEIL_TEST_UPDATER_OK:profile:after, y su línea ARVEIL_TEST_UPDATER_STATE, la secuencia conservada y el paquete eliminado. ARVEIL_TEST_TRUST_PROBE hace que ambos indiquen si llegan a una dirección HTTPS con las raíces del sistema y con las del actualizador.