forked from upstream-mirrors/quik
Use persistent release signing key and verify APK certificates in CI
Build and Release APK / Build APK (push) Successful in 8m18s
Build and Release APK / Build APK (push) Successful in 8m18s
This commit is contained in:
Binary file not shown.
@@ -0,0 +1 @@
|
||||
024f3dc752d85c7fd5d9c1ccf3512c2120a5ef3cb3e6d7b2c73eebaaf6294469
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -59,3 +59,7 @@ keystore.p12
|
||||
config.yml
|
||||
*.jks
|
||||
|
||||
|
||||
# Python CI tooling
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -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
@@ -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.')
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user