diff --git a/.gitea/release-signing.crt b/.gitea/release-signing.crt new file mode 100644 index 00000000..9231ef63 Binary files /dev/null and b/.gitea/release-signing.crt differ diff --git a/.gitea/release-signing.sha256 b/.gitea/release-signing.sha256 new file mode 100644 index 00000000..e78ac80a --- /dev/null +++ b/.gitea/release-signing.sha256 @@ -0,0 +1 @@ +024f3dc752d85c7fd5d9c1ccf3512c2120a5ef3cb3e6d7b2c73eebaaf6294469 diff --git a/.gitea/scripts/signing.py b/.gitea/scripts/signing.py new file mode 100644 index 00000000..704dfc1b --- /dev/null +++ b/.gitea/scripts/signing.py @@ -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()) diff --git a/.gitea/tests/test_signing.py b/.gitea/tests/test_signing.py new file mode 100644 index 00000000..82fa4768 --- /dev/null +++ b/.gitea/tests/test_signing.py @@ -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('' + '' + '') + 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() diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 84f2a731..daf369b3 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore index 128c697c..8d1af58e 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,7 @@ keystore.p12 config.yml *.jks + +# Python CI tooling +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md index b74c09f9..8bfc6d2b 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/presentation/build.gradle b/presentation/build.gradle index f8b6dec0..1ca4e42f 100644 --- a/presentation/build.gradle +++ b/presentation/build.gradle @@ -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.') } }