Каждый Android-разработчик хоть раз набирал keytool -genkeypair, вбивал пароль, прятал файл .jks подальше и забывал о нём до следующего релиза. При этом от этого файла зависит больше, чем от любой строчки кода: код можно переписать, а потерянный ключ не восстановит никто.
Поводом разобраться глубже для меня стал релиз маленького будильника сразу в три места: Google Play, RuStore и собственный сайт. Именно на стыке этих площадок сидит ловушка, в которую легко попасть на первой же загрузке. Ниже разбор по порядку: что такое ключ подписи с точки зрения платформы, как менялись схемы подписи, что можно безопасно делать с хранилищем, как ключ хранить и менять и как правильно выпустить приложение в нескольких магазинах.
Что такое ключ подписи
Подпись APK построена на асимметричной криптографии. Генерируется пара ключей:
- закрытый ключ хранится в секрете, им создают подпись;
- открытый ключ публикуется, им подпись проверяют.
Подпись в современных схемах выглядит так. Сборщик считает криптографический хэш содержимого APK и вычисляет над ним подпись закрытым ключом, алгоритмом RSA, ECDSA или DSA. Устройство при установке заново считает хэш и проверяет подпись открытым ключом. Если хоть один байт изменился после подписи, проверка не пройдёт.
Открытый ключ сам по себе ничего не говорит о владельце. Поэтому в APK кладут не голый ключ, а сертификат X.509: открытый ключ плюс имя субъекта (CN=..., O=..., C=...), срок действия, серийный номер и подпись, которая скрепляет эти поля. Для Android этот сертификат и есть «подписант» приложения.
Откуда это взялось
- 1976. Диффи и Хеллман публикуют «New Directions in Cryptography», первую открытую работу о криптографии с открытым ключом.
- 1977. Ривест, Шамир и Адлеман предлагают RSA, первый практичный алгоритм шифрования и подписи с открытым ключом.
- 1988. Выходит стандарт сертификатов X.509. Его третья версия (1996) с расширениями живёт в каждом APK до сих пор.
- 1997-1998. Java 1.1 учится подписывать JAR-архивы. Java 1.2 приносит
keytool,jarsignerи формат хранилища JKS. - 2008. Android 1.0 берёт подпись JAR без изменений: APK это ZIP-архив, то есть по сути JAR. Позже эту схему назовут v1.
Отсюда и «джавовый» набор инструментов: он старше Android на десять лет.
Модель доверия: удостоверяющего центра нет
В вебе сертификат сайта подписан удостоверяющим центром, и браузер проверяет цепочку доверия. В Android этого нет: в документации AOSP прямо сказано, что платформа не выполняет проверку сертификатов приложений через удостоверяющие центры. Сертификат почти всегда самоподписанный, и это штатная ситуация.
Android проверяет не личность, а преемственность. Обновление устанавливается поверх приложения, только если его подписант совпадает с подписантом уже установленной версии. Кто первым поставил на устройство пакет com.example.app, тот им на этом устройстве и владеет. Чужой APK с тем же именем пакета поверх не встанет.
Из этой модели следует несколько неочевидных вещей.
Срок действия сертификата установке не мешает. Платформа не отказывает в установке APK из-за истёкшего сертификата. Требование к сроку есть у Google Play: сертификат должен действовать дольше 22 октября 2033 года, а сам Google советует закладывать не меньше 25 лет. Отсюда привычное -validity 10000, то есть около 27 лет.
Имя в сертификате ничего не удостоверяет. В CN можно написать что угодно, никто это не проверяет. Оно видно только в выводе apksigner verify --print-certs.
Сравнивается сертификат, а не только ключ. Если перевыпустить сертификат на тот же закрытый ключ, например с новым сроком или другим именем, получится другой набор байтов, и для Android это другой подписант. Сменить подписанта законно можно только через ротацию ключа, о ней ниже.
Подпись даёт доступ к «своим». Разрешения с уровнем защиты signature система выдаёт только приложениям, подписанным тем же сертификатом, что и приложение, объявившее разрешение. На этом строятся закрытые взаимодействия внутри семьи приложений одного автора. Старый механизм sharedUserId тоже опирался на общую подпись, но он объявлен устаревшим.
Отпечаток сертификата это идентификатор приложения во внешних сервисах. SHA-256 отпечаток прописывают в Firebase, в настройках входа через Google, в файле assetlinks.json для App Links. Смена ключа означает перенастройку всех этих интеграций.
Что лежит в keystore
Файл .jks, .keystore или .p12 это хранилище ключей, контейнер. Расширение ничего не значит, значит формат. Внутри лежат записи, каждая под своим именем, alias. Запись с ключом подписи (PrivateKeyEntry) содержит закрытый ключ и цепочку сертификатов, для Android-подписи обычно из одного самоподписанного сертификата.
Паролей два: пароль хранилища защищает целостность и содержимое файла, пароль ключа защищает конкретную запись. Отсюда два поля в signingConfigs.
| Формат | Происхождение | Статус |
|---|---|---|
| JKS | собственный формат Sun, Java 1.2 | устаревший, keytool при работе с ним предлагает миграцию |
| PKCS12 | открытый стандарт PKCS #12 из RSA Laboratories, понимают OpenSSL и другие платформы | формат по умолчанию начиная с Java 9 |
Особенность PKCS12, на которой спотыкаются: keytool не поддерживает для него разные пароли хранилища и ключа. Поэтому в keystore.properties для PKCS12 оба пароля одинаковые, и это не ошибка.
Алгоритм и длина ключа. Самый совместимый выбор RSA: его понимают все версии Android, все сторы и утилита PEPK для передачи ключа в магазин. RSA 2048 соответствует нынешним рекомендациям, RSA 4096 стоит только немного более длинной подписи и медленнее генерируется. ECDSA даёт компактные ключи и подписи, но выигрыш для APK несущественный, а совместимость со сторонними инструментами надо проверять. Ключ выбирается один раз и навсегда, поэтому в этом месте не экспериментируют.
Debug-ключ
Сборка из Android Studio тоже подписана. Ключ лежит в ~/.android/debug.keystore, создаётся автоматически и имеет стандартные параметры: пароль android, alias androiddebugkey, имя CN=Android Debug,O=Android,C=US.
Параметры у всех одинаковые, а сам ключ на каждом компьютере свой. Поэтому debug-сборка с одного компьютера не встанет поверх debug-сборки с другого: знакомая ошибка INSTALL_FAILED_UPDATE_INCOMPATIBLE после смены рабочей машины. Команде удобно держать общий debug-ключ в репозитории, это нормально: он ничего не защищает. Сторы APK с debug-подписью не принимают.
Схемы подписи: v1, v2, v3, v4
За время жизни Android схему подписи меняли несколько раз, и каждая версия закрывала конкретную проблему.
v1, подпись JAR (Android 1.0). В META-INF лежат MANIFEST.MF с хэшами каждого файла архива, файл .SF с хэшами записей манифеста и блок подписи .RSA, .EC или .DSA. Подписаны отдельные файлы внутри архива, а не архив целиком, и метаданные ZIP не защищены. На этом построены две громкие уязвимости: Master Key (2013), где эксплуатировались записи с одинаковыми именами в ZIP, и Janus (CVE-2017-13156), где к APK можно было приписать DEX-файл, не нарушив подпись v1. Кроме того, для проверки v1 нужно распаковать и прохэшировать все файлы, а это медленно.
v2 (Android 7.0). Подписывается файл целиком. Подпись лежит в отдельном блоке подписи APK между содержимым архива и его центральным каталогом. Любое изменение байта после подписи ломает её, а проверка идёт заметно быстрее. Практическое следствие: zipalign выполняется до подписи, после подписи APK трогать нельзя.
v3 (Android 9). Тот же принцип, что у v2, плюс ротация ключа. В блок подписи кладётся «родословная» (proof-of-rotation): цепочка сертификатов, где каждый предыдущий ключ подписью подтверждает доверие следующему. Устройство, на котором приложение стоит со старым ключом, примет обновление, подписанное новым.
v3.1 (Android 13). Позволяет применить ротацию только начиная с определённой версии Android. Старые устройства продолжают получать APK со старым ключом, новые с новым. Так ротацию можно внедрить, не задевая устройства, где поведение платформы вокруг неё было сложнее.
v4 (Android 11). Отдельный файл .idsig рядом с APK с деревом хэшей для потоковой установки через ADB Incremental: приложение запускается раньше, чем полностью скопировано на устройство. v4 не самостоятельна и дополняет подпись v2 или v3.
Схемы не заменяют друг друга, а накладываются: современный APK обычно несёт v2 и v3, а для старых устройств ещё и v1. apksigner и Android Gradle Plugin выбирают набор схем по minSdk: если приложение не поддерживает версии ниже Android 7.0, подпись v1 не нужна.
Что можно безопасно делать с хранилищем
Основной инструмент keytool, он есть в любом JDK, в том числе в jbr/bin внутри Android Studio.
Посмотреть содержимое и отпечатки:
keytool -list -v -keystore release.jks
Сменить пароль хранилища:
keytool -storepasswd -keystore release.jks
Сменить пароль ключа (только для JKS, в PKCS12 он всегда равен паролю хранилища):
keytool -keypasswd -alias app -keystore release.jks
Переименовать запись:
keytool -changealias -alias old -destalias app -keystore release.jks
Перевести JKS в PKCS12:
keytool -importkeystore -srckeystore release.jks \
-destkeystore release.p12 -deststoretype PKCS12
Все эти операции безопасны. Пароли, alias и формат контейнера не попадают в APK: туда попадает только сертификат. После них приложение подписывается тем же сертификатом, и обновления ставятся как раньше.
Небезопасно всё, что меняет сам сертификат. Например, keytool -selfcert перевыпустит самоподписанный сертификат на тот же ключ, и Android увидит нового подписанта. Сменить подписанта законно можно только ротацией.
Проверить подпись готового APK (утилита лежит в build-tools Android SDK):
apksigner verify --verbose --print-certs app-release.apk
Вывод покажет, какими схемами подписан файл, и отпечатки сертификата. CN=Android Debug в выводе означает, что релиз собран не тем ключом.
Подпись в Gradle
Пароли и путь к хранилищу не должны попадать в git. Стандартный приём: файл keystore.properties в корне проекта, внесённый в .gitignore:
storeFile=/secure/path/release.jks
storePassword=...
keyAlias=app
keyPassword=...
И конфигурация в app/build.gradle.kts, которая не ломает сборку, когда файла нет, например у нового участника команды:
import java.util.Properties
val keystoreProperties = Properties().apply {
val file = rootProject.file("keystore.properties")
if (file.exists()) file.inputStream().use { load(it) }
}
android {
signingConfigs {
create("release") {
if (keystoreProperties.containsKey("storeFile")) {
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
}
}
}
В CI хранилище обычно передают секретом в base64 и восстанавливают в файл перед сборкой, а пароли берут из переменных окружения:
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks"
Секреты CI шифруются, но ключ в CI всё равно доступен всем, кто может менять пайплайн. Для Google Play это приемлемо, если в CI лежит ключ загрузки, а не ключ приложения: утечка ключа загрузки исправима, утечка ключа приложения нет.
Play App Signing: два ключа вместо одного
До 2017 года потеря ключа означала конец приложения: выпустить обновление невозможно, остаётся публиковать новое приложение под другим именем пакета, а отзывы, рейтинг и установки остаются на старой карточке.
В 2017 году Google запустил Play App Signing, а с августа 2021 года он обязателен для новых приложений вместе с форматом AAB. Схема такая:
- ключ приложения хранит Google. Им подписываются APK, которые Play собирает из вашего AAB и отдаёт пользователям;
- ключ загрузки хранится у вас. Им вы подписываете AAB при отправке в консоль, Google проверяет по нему, что загрузка от вас.
Если ключ загрузки потерян или скомпрометирован, в консоли запрашивают его сброс: регистрируют новый сертификат загрузки, и через некоторое время подтверждения им можно пользоваться. Пользователи этого не замечают: ключ приложения не меняется.
У схемы есть цена, о которой редко говорят: Google может подписать любую сборку вашим ключом приложения. Для проверки того, что код в раздаваемых APK именно ваш, есть Code Transparency: отдельный ключ, который есть только у разработчика, подписывает список хэшей DEX-файлов и нативных библиотек из бандла. Ограничения важны: ресурсы и манифест этим не проверяются, а Android при установке файл прозрачности не смотрит, проверка делается вручную через bundletool.
Ключ приложения в Play можно и сменить: обновление ключа делается из консоли, новый ключ используется для установок и обновлений на Android 13 и выше, а на более старых версиях по-прежнему старый. Под капотом это та же ротация v3.1.
Ротация ключа вне Play
Для APK, которые вы подписываете сами, ротация делается через apksigner. Сначала создаётся родословная, где старый ключ подписывает доверие новому:
apksigner rotate --out lineage.bin \
--old-signer --ks old.jks \
--new-signer --ks new.jks
Затем APK подписывается новым ключом с приложенной родословной, и опционально с --rotation-min-sdk-version, чтобы ротация применялась только с Android 13. Старый ключ после этого всё равно нужно хранить: без него нельзя продолжить родословную, а устройства на Android ниже 9 про ротацию не знают вовсе.
Из кода текущую подпись и историю ротации можно получить через PackageManager.GET_SIGNING_CERTIFICATES (API 28 и выше): SigningInfo вернёт либо текущих подписантов, либо историю сертификатов с учётом ротации. Этим пользуются для проверки собственной подписи против перепаковки, хотя всерьёз защищает от неё серверная проверка вроде Play Integrity, а не проверка на устройстве.
Одно приложение, несколько площадок
Теперь та самая история с будильником. Задача: выложить приложение в Google Play, в RuStore и APK на собственный сайт.
Android позволяет обновлять приложение из любого источника, если подписант тот же. Человек поставил APK с сайта, затем нашёл приложение в RuStore и хочет получать обновления оттуда. Совпадают подписи, и обновление встанет. Не совпадают, и Android откажет, а выход останется один: удалить приложение вместе с данными.
Ловушка в том, что при включении Play App Signing консоль по умолчанию генерирует ключ приложения сама, и этот ключ разработчику не выдаётся. APK из Google Play окажутся подписаны ключом Google, а APK из RuStore и с сайта вашим. Три источника станут взаимно несовместимы, и после первого релиза это уже не исправить: обновление ключа в Play действует только на Android 13 и выше, так что на старых устройствах несовместимость останется навсегда.
Правильная схема выглядит так:
ключ приложения (хранится у вас)
├─ копия в Play через PEPK
│ └─ Play подписывает APK
│ для пользователей
│ (сам AAB вы загружаете,
│ подписав ключом загрузки)
├─ RuStore: APK
└─ сайт: APK
Порядок действий:
- Создать ключ приложения и хранить его как самое ценное, что есть у проекта.
- Создать отдельный ключ загрузки для Google Play.
- При настройке подписи в Play Console выбрать загрузку своего ключа (Export and upload a key from Java keystore) и передать ключ приложения, зашифрованный утилитой PEPK. После этого Google подписывает APK вашим ключом.
- Google Play:
./gradlew bundleRelease, AAB подписан ключом загрузки. - RuStore и сайт:
./gradlew assembleRelease, APK подписан ключом приложения. - Во всех каналах одинаковые
versionCodeиversionName.
Как устроены другие площадки:
- RuStore не генерирует ключи разработчика и принимает как APK, так и AAB. Для AAB в консоль загружают ключ приложения, упакованный той же PEPK, и сертификат ключа загрузки в PEM. При переходе с APK на AAB отпечаток подписи обязан совпасть с предыдущей версией, а новая версия с другой подписью для RuStore равносильна попытке поставить другое приложение поверх.
- F-Droid по умолчанию собирает приложение из исходников и подписывает своим ключом, поэтому версия из F-Droid несовместима с версией из других источников. Выход дают воспроизводимые сборки: если сборка F-Droid побайтно совпала с вашей, F-Droid публикует APK с вашей подписью.
- Huawei AppGallery и другие магазины с собственной службой переподписи требуют того же внимания, что и Play: если площадка генерирует ключ за вас, совместимость с остальными каналами теряется.
Проверить, что всё сошлось, можно на устройстве. Play ставит набор split-APK, поэтому сначала нужно найти базовый:
adb shell pm path com.example.app
adb pull /data/app/.../base.apk
apksigner verify --print-certs base.apk
SHA-256 отпечаток должен совпасть с отпечатком вашего ключа приложения из keytool -list -v.
Одинаковая подпись не значит бесшумное обновление. С Android 14 установщик может объявить себя владельцем обновлений приложения. Если обновление придёт из другого источника, система спросит пользователя, продолжить ли. Подпись при этом проверяется как обычно, просто появляется ещё один диалог.
Верификация разработчиков: ключ становится удостоверением
Долгое время ключ подписи был полностью анонимным. Это меняется. Google вводит верификацию разработчиков Android: на сертифицированных устройствах с Android 7 и выше будут устанавливаться и обновляться только приложения, зарегистрированные верифицированным разработчиком, в том числе распространяемые вне Google Play.
Ключ подписи здесь центральный. Как сказано в FAQ, право на имя пакета подтверждается сертификатом подписи. Для одного пакета можно зарегистрировать несколько ключей, например ключ Play и ключ для других каналов. Приложения с Play App Signing регистрируются автоматически. А если ключ потерян, зарегистрировать пакет не получится.
Сроки на момент написания:
- с 30 сентября 2026 года проверка включается в Бразилии, Индонезии, Сингапуре и Таиланде для установок из участвующих магазинов;
- в 2027 году планируется расширение на сертифицированные устройства по всему миру;
- установка через ADB и «расширенный» сценарий для опытных пользователей с одноразовой настройкой остаются.
Для разработчика, который раздаёт APK с сайта, практический вывод простой: ключ, которым подписаны APK вне Play, придётся зарегистрировать, так что с самого начала стоит держать один ключ приложения на все каналы. Правила ещё уточняются, поэтому перед релизом их стоит перепроверить.
Хранение: как не потерять ключ
- Файл хранилища никогда не лежит в репозитории, даже приватном.
- Минимум две копии: рабочая и оффлайн, на носителе в физически другом месте. Облачное хранилище не заменяет оффлайн-копию.
- Пароли, alias и SHA-256 отпечаток в менеджере паролей, с пометкой, от какого приложения ключ.
- Ключ приложения и ключ загрузки разные. В CI лежит только ключ загрузки.
- Раз в год проверять, что копия открывается и отпечаток совпадает с тем, что в сторах.
Шпаргалка
- Подписант приложения это сертификат X.509. Android проверяет не личность, а совпадение подписанта при обновлении.
- Перевыпуск сертификата на тот же ключ равен смене подписи. Менять подписанта можно только ротацией (v3, v3.1).
- Пароли, alias и формат хранилища менять безопасно. PKCS12 предпочтительнее JKS, пароль ключа в нём равен паролю хранилища.
- v2 и выше подписывают весь файл:
zipalignдо подписи, не после. - В Google Play обязателен Play App Signing. Если приложение будет жить где-то ещё, загружайте в Play свой ключ приложения, а не соглашайтесь на сгенерированный.
- RuStore для AAB принимает ваш ключ через PEPK, F-Droid подписывает своим, если сборка не воспроизводима.
- С верификацией разработчиков ключ подписи становится доказательством права на пакет, в том числе вне Play.
Ещё про Android в этом блоге: как взять под контроль виджеты на Glance.