Use persistent release signing key and verify APK certificates in CI
Build and Release APK / Build APK (push) Successful in 8m18s

This commit is contained in:
2026-10-18 00:18:22 +02:00
parent 792d548715
commit 77d3ff7e1e
8 changed files with 306 additions and 29 deletions
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
024f3dc752d85c7fd5d9c1ccf3512c2120a5ef3cb3e6d7b2c73eebaaf6294469
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Restore the CI signing key and verify its public certificate before publishing."""
import argparse
import base64
import hashlib
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[2]
PIN = ROOT / '.gitea/release-signing.sha256'
KEYSTORE = ROOT / '.secrets/ci-release.p12'
REQUIRED = ('ANDROID_KEYSTORE_BASE64', 'ANDROID_KEYSTORE_PASSWORD',
'ANDROID_KEY_ALIAS', 'ANDROID_KEY_PASSWORD')
def expected_fingerprint():
value = PIN.read_text().strip().lower()
if not re.fullmatch(r'[0-9a-f]{64}', value):
raise ValueError('Invalid pinned release certificate SHA-256')
return value
def keytool():
home = os.environ.get('JAVA_HOME')
return str(Path(home) / 'bin/keytool') if home else 'keytool'
def apksigner():
executable = shutil.which('apksigner')
if executable:
return executable
sdk = os.environ.get('ANDROID_HOME') or os.environ.get('ANDROID_SDK_ROOT')
tools = list((Path(sdk) / 'build-tools').glob('*/apksigner')) if sdk else []
if not tools:
raise ValueError('apksigner not found; set ANDROID_HOME or PATH')
return str(max(tools, key=lambda p: tuple(map(int, re.findall(r'\d+', p.parent.name)))))
def prepare():
missing = [name for name in REQUIRED if not os.environ.get(name)]
if missing:
raise ValueError('Missing signing secrets: ' + ', '.join(missing))
expected = expected_fingerprint()
try:
data = base64.b64decode(''.join(os.environ[REQUIRED[0]].split()), validate=True)
except ValueError:
raise ValueError('ANDROID_KEYSTORE_BASE64 is not valid base64') from None
KEYSTORE.parent.mkdir(mode=0o700, exist_ok=True)
# Never leave a partially decoded or unverified signing key behind.
try:
with open(KEYSTORE, 'wb', opener=lambda p, flags: os.open(p, flags, 0o600)) as stream:
os.chmod(KEYSTORE, 0o600)
stream.write(data)
certificate = subprocess.check_output([
keytool(), '-exportcert', '-keystore', str(KEYSTORE),
'-alias', os.environ['ANDROID_KEY_ALIAS'],
'-storepass:env', 'ANDROID_KEYSTORE_PASSWORD'
], stderr=subprocess.PIPE)
actual = hashlib.sha256(certificate).hexdigest()
if actual != expected:
raise ValueError('Signing key does not match the pinned release certificate')
with open(os.environ['GITHUB_ENV'], 'a') as stream:
stream.write(f'ANDROID_KEYSTORE_PATH={KEYSTORE}\n')
print(f'Release signing certificate verified: {actual}')
except Exception:
KEYSTORE.unlink(missing_ok=True)
raise
def verify(apks):
expected = expected_fingerprint()
for apk in apks:
output = subprocess.check_output(
[apksigner(), 'verify', '--print-certs', apk], text=True, stderr=subprocess.PIPE)
fingerprints = re.findall(r'Signer #\d+ certificate SHA-256 digest: ([0-9a-f]+)', output)
if fingerprints != [expected]:
raise ValueError(f'{Path(apk).name}: APK signer does not match the pinned release certificate')
print(f'{Path(apk).name}: release signature verified ({expected})')
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest='command', required=True)
commands.add_parser('prepare')
commands.add_parser('cleanup')
commands.add_parser('verify').add_argument('apks', nargs='+')
args = parser.parse_args()
try:
if args.command == 'prepare':
prepare()
elif args.command == 'verify':
verify(args.apks)
else:
KEYSTORE.unlink(missing_ok=True)
except subprocess.CalledProcessError:
print('Signing tool failed. Check the keystore, alias and passwords.', file=sys.stderr)
return 1
except (OSError, ValueError) as error:
print(str(error), file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())
+113
View File
@@ -0,0 +1,113 @@
import base64
import contextlib
import hashlib
import importlib.util
import io
import os
from pathlib import Path
import stat
import subprocess
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location('signing', Path(__file__).parents[1] / 'scripts/signing.py')
signing = importlib.util.module_from_spec(spec)
spec.loader.exec_module(signing)
class SigningTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.folder = tempfile.TemporaryDirectory()
cls.root = Path(cls.folder.name)
cls.key = cls.root / 'release.p12'
cls.password = 'temporary-test-password'
cls.env = dict(os.environ, TEST_KEY_PASSWORD=cls.password)
subprocess.run([signing.keytool(), '-genkeypair', '-noprompt', '-storetype', 'PKCS12',
'-keystore', str(cls.key), '-alias', 'release', '-keyalg', 'RSA',
'-keysize', '2048', '-validity', '30', '-dname', 'CN=Signing test',
'-storepass:env', 'TEST_KEY_PASSWORD', '-keypass:env', 'TEST_KEY_PASSWORD'],
env=cls.env, check=True, capture_output=True)
cert = subprocess.check_output([signing.keytool(), '-exportcert', '-keystore', str(cls.key),
'-alias', 'release', '-storepass:env', 'TEST_KEY_PASSWORD'], env=cls.env)
cls.fingerprint = hashlib.sha256(cert).hexdigest()
sdk = Path(os.environ.get('ANDROID_HOME') or os.environ['ANDROID_SDK_ROOT'])
manifest = cls.root / 'AndroidManifest.xml'
manifest.write_text('<manifest xmlns:android="http://schemas.android.com/apk/res/android" '
'package="test.signing" android:versionCode="1" android:versionName="1">'
'<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="33"/>'
'<application android:label="Signing test"/></manifest>')
cls.unsigned = cls.root / 'unsigned.apk'
aapt = str(Path(signing.apksigner()).with_name('aapt'))
platform = next(sdk.glob('platforms/android-34/android.jar'), None) or next(sdk.glob('platforms/*/android.jar'))
subprocess.run([aapt, 'package', '-f', '-M', str(manifest), '-I', str(platform),
'-F', str(cls.unsigned)], check=True, capture_output=True)
cls.signed = cls.root / 'signed.apk'
subprocess.run([signing.apksigner(), 'sign', '--ks', str(cls.key), '--ks-key-alias', 'release',
'--ks-pass', 'env:TEST_KEY_PASSWORD', '--key-pass', 'env:TEST_KEY_PASSWORD',
'--out', str(cls.signed), str(cls.unsigned)], env=cls.env, check=True, capture_output=True)
@classmethod
def tearDownClass(cls):
cls.folder.cleanup()
def setUp(self):
self.work = tempfile.TemporaryDirectory()
self.addCleanup(self.work.cleanup)
root = Path(self.work.name)
self.pin = root / 'certificate.sha256'
self.pin.write_text(self.fingerprint + '\n')
self.store = root / 'private/key.p12'
self.output = root / 'github.env'
self.values = dict(ANDROID_KEYSTORE_BASE64=base64.b64encode(self.key.read_bytes()).decode(),
ANDROID_KEYSTORE_PASSWORD=self.password, ANDROID_KEY_ALIAS='release',
ANDROID_KEY_PASSWORD=self.password, GITHUB_ENV=str(self.output))
self.addCleanup(patch.stopall)
patch.object(signing, 'PIN', self.pin).start()
patch.object(signing, 'KEYSTORE', self.store).start()
patch.dict(os.environ, self.values).start()
def test_restores_expected_key_privately_without_exporting_passwords(self):
with contextlib.redirect_stdout(io.StringIO()):
signing.prepare()
self.assertEqual(self.key.read_bytes(), self.store.read_bytes())
self.assertEqual(0o600, stat.S_IMODE(self.store.stat().st_mode))
self.assertEqual(f'ANDROID_KEYSTORE_PATH={self.store}\n', self.output.read_text())
self.assertNotIn(self.password, self.output.read_text())
def test_missing_secrets_stop_before_creating_key(self):
del os.environ['ANDROID_KEYSTORE_BASE64']
with self.assertRaisesRegex(ValueError, 'Missing signing secrets'):
signing.prepare()
self.assertFalse(self.store.exists())
def test_wrong_certificate_is_rejected_and_private_file_removed(self):
self.pin.write_text('0' * 64)
with self.assertRaisesRegex(ValueError, 'does not match'):
signing.prepare()
self.assertFalse(self.store.exists())
self.assertFalse(self.output.exists())
def test_wrong_password_is_rejected_and_private_file_removed(self):
os.environ['ANDROID_KEYSTORE_PASSWORD'] = 'wrong-password'
with self.assertRaises(subprocess.CalledProcessError):
signing.prepare()
self.assertFalse(self.store.exists())
def test_apk_with_expected_signature_is_accepted(self):
with contextlib.redirect_stdout(io.StringIO()):
signing.verify([str(self.signed)])
def test_apk_with_another_signature_is_rejected(self):
self.pin.write_text('0' * 64)
with self.assertRaisesRegex(ValueError, 'does not match'):
signing.verify([str(self.signed)])
def test_unsigned_apk_is_rejected(self):
with self.assertRaises(subprocess.CalledProcessError):
signing.verify([str(self.unsigned)])
if __name__ == '__main__':
unittest.main()
+27 -1
View File
@@ -17,6 +17,18 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Test release signing checks
run: python3 -m unittest discover -s .gitea/tests -v
- name: Restore and validate release signing key
if: github.event_name != 'pull_request'
run: python3 .gitea/scripts/signing.py prepare
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
- name: Restore Gradle Cache
uses: actions/cache@v3
with:
@@ -51,7 +63,7 @@ jobs:
env:
BUILD_NUMBER: ${{ github.run_number }}
- name: Test Matrix room resolution
- name: Test Matrix bridge
run: ./gradlew :remotrix-sync:testDebugUnitTest
- name: Build Debug APK
@@ -62,13 +74,23 @@ jobs:
exit 1
- name: Build Release APK
if: github.event_name != 'pull_request'
env:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
for i in 1 2 3; do
./gradlew assembleRelease && exit 0 || { echo "Attempt $i failed, retrying in 30s..."; sleep 30; }
done
exit 1
- name: Verify release APK signature
if: github.event_name != 'pull_request'
run: python3 .gitea/scripts/signing.py verify presentation/build/outputs/apk/release/*.apk
- name: Create Release
if: github.event_name != 'pull_request'
uses: https://gitea.com/actions/gitea-release-action@v1
with:
tag_name: "v${{ env.APP_VERSION }}"
@@ -79,3 +101,7 @@ jobs:
presentation/build/outputs/apk/release/*.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Remove temporary signing key
if: always()
run: python3 .gitea/scripts/signing.py cleanup
+4
View File
@@ -59,3 +59,7 @@ keystore.p12
config.yml
*.jks
# Python CI tooling
__pycache__/
*.py[cod]
+29
View File
@@ -33,6 +33,35 @@ The reset removes local Matrix accounts, room mappings, encryption stores and br
SMS messages, Quik settings, the Manager ID and the backfill limit are preserved.
New rooms use contact names from Android, and SMS history is copied again up to the configured limit.
## Persistent release signing
Release APKs require the permanent signing key. Missing signing configuration fails the build;
release builds never fall back to a generated debug key. Pull requests build and test Debug
without release secrets and do not publish releases.
Gitea repository **Settings → Actions → Secrets** must contain:
- `ANDROID_KEYSTORE_BASE64`: base64-encoded permanent PKCS12 keystore.
- `ANDROID_KEYSTORE_PASSWORD`: keystore password.
- `ANDROID_KEY_ALIAS`: signing key alias.
- `ANDROID_KEY_PASSWORD`: private key password.
The public certificate and its SHA-256 fingerprint are tracked in `.gitea/release-signing.crt`
and `.gitea/release-signing.sha256`. CI checks the restored key and the finished Release APK
against this fingerprint before publishing. The temporary CI key is removed even after failure.
Do not regenerate the signing key between builds; back up the key and passwords securely.
The initial local backup is `.secrets/quik-release.p12` with credentials in
`.secrets/release-signing.json`. These private files are ignored by Git and must not be committed.
For a local Release build, set `ANDROID_KEYSTORE_PATH`, `ANDROID_KEYSTORE_PASSWORD`,
`ANDROID_KEY_ALIAS` and `ANDROID_KEY_PASSWORD`. Existing local `.gradle/.gradlerc` properties
and the legacy CI environment names are also supported. Debug APKs use a separate application ID
and are intended for development.
The former builds 50 and 51 used different disposable debug certificates. The new permanent key
cannot update either installation in place; migrating once to the new certificate requires a
reinstall. Subsequent Release updates keep the same signature.
## Building and Releasing (Fork CI/CD)
The project uses Gitea Actions for CI/CD:
+23 -28
View File
@@ -21,6 +21,18 @@ apply plugin: 'realm-android' // Realm needs to be before Kotlin or the build wi
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
// One explicit signing configuration for release builds; never fall back to debug.
def releaseKeyProperties = new Properties()
def releaseKeyPropertiesFile = rootProject.file('.gradle/.gradlerc')
if (releaseKeyPropertiesFile.exists()) {
releaseKeyPropertiesFile.withInputStream { releaseKeyProperties.load(it) }
}
def releaseKeyPath = System.getenv('ANDROID_KEYSTORE_PATH')
def releaseKeyFile = releaseKeyPath ? rootProject.file(releaseKeyPath) : file('my-release-key.keystore')
def releaseStorePassword = System.getenv('ANDROID_KEYSTORE_PASSWORD') ?: System.getenv('keystore_password') ?: releaseKeyProperties['storePassword']
def releaseKeyAlias = System.getenv('ANDROID_KEY_ALIAS') ?: System.getenv('key_alias') ?: releaseKeyProperties['keyAlias'] ?: 'quik_release'
def releaseKeyPassword = System.getenv('ANDROID_KEY_PASSWORD') ?: System.getenv('key_password') ?: releaseKeyProperties['keyPassword']
android {
compileSdk 34
@@ -37,17 +49,10 @@ android {
signingConfigs {
debug // Uses the default debug keystore.
release {
def keystoreProps = new Properties()
def keystorePropsFile = rootProject.file('./.gradle/.gradlerc')
storeFile file('./my-release-key.keystore')
keyAlias 'quik_release'
if (keystorePropsFile.exists()) {
keystoreProps.load(new FileInputStream(keystorePropsFile))
} else {
// do nothing
}
storePassword keystoreProps['storePassword']
keyPassword keystoreProps['keyPassword']
storeFile releaseKeyFile
storePassword releaseStorePassword
keyAlias releaseKeyAlias
keyPassword releaseKeyPassword
}
}
@@ -63,11 +68,7 @@ android {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
if (file('./my-release-key.keystore').exists()) {
signingConfig signingConfigs.release
} else {
signingConfig signingConfigs.debug
}
signingConfig signingConfigs.release
}
debug {
applicationIdSuffix ".debug"
@@ -109,12 +110,6 @@ android {
exclude 'META-INF/INDEX.LIST'
exclude 'META-INF/DEPENDENCIES'
}
if (System.getenv("CI") == "true") {
signingConfigs.release.storePassword = System.getenv("keystore_password")
signingConfigs.release.keyAlias = System.getenv("key_alias")
signingConfigs.release.keyPassword = System.getenv("key_password")
}
}
configurations {
@@ -225,12 +220,12 @@ dependencies {
implementation project(':remotrix-sync')
}
// Check to make sure that the keystore file is present when building a release
// Validate only signed variants so local debug builds and PR checks need no secrets.
gradle.taskGraph.whenReady { taskGraph ->
if ((taskGraph.hasTask(':assembleRelease') ||
taskGraph.allTasks.any { it.name == 'assembleRelease' }) &&
!file('./.gradle/.gradlerc').exists() &&
System.getenv("CI") != "true") {
throw new GradleException("Keystore properties file not found")
def signedBuild = taskGraph.allTasks.any {
it.project == project && (it.name == 'preReleaseBuild' || it.name == 'preFdroidBuild')
}
if (signedBuild && (!releaseKeyFile.isFile() || !releaseStorePassword || !releaseKeyAlias || !releaseKeyPassword)) {
throw new GradleException('Release signing is not configured. Supply the persistent release keystore and passwords; debug signing is forbidden for release builds. See README.md.')
}
}