fix: keep release signing local

This commit is contained in:
callebtc 2026-07-29 02:06:43 +02:00
parent a0048554c6
commit da5650ceb4
6 changed files with 339 additions and 106 deletions

View File

@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
tag:
description: Existing vX.Y.Z tag to build and publish
description: Existing vX.Y.Z tag to build and attest
required: true
type: string
push:
@ -15,7 +15,6 @@ permissions:
contents: read
env:
JAVA_VERSION: 21.0.11+10
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
jobs:
@ -40,6 +39,14 @@ jobs:
with:
ref: ${{ env.RELEASE_TAG }}
- name: Verify workflow provenance ref
run: |
checked_out_commit="$(git rev-parse HEAD)"
if [ "$checked_out_commit" != "$GITHUB_SHA" ]; then
echo "error: run the workflow from the $RELEASE_TAG tag ref so provenance identifies the built commit" >&2
exit 1
fi
- name: Validate the Gradle wrapper
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
@ -60,6 +67,10 @@ jobs:
name: Verify reproducibility
runs-on: ubuntu-24.04
needs: build
permissions:
attestations: write
contents: read
id-token: write
steps:
- name: Checkout verification script
@ -82,6 +93,23 @@ jobs:
- name: Compare every canonical byte
run: tools/reproducible-builds/compare-release.sh "$RUNNER_TEMP/release-a" "$RUNNER_TEMP/release-b"
- name: Prepare public attestation subjects
run: |
mkdir "$RUNNER_TEMP/attestation-subjects"
cp "$RUNNER_TEMP/release-a/BUILDINFO.json" \
"$RUNNER_TEMP/attestation-subjects/BITCHAT_BUILDINFO.json"
cp "$RUNNER_TEMP/release-a/SHA256SUMS.unsigned" \
"$RUNNER_TEMP/attestation-subjects/BITCHAT_SHA256SUMS.unsigned"
cp "$RUNNER_TEMP"/release-a/bitchat-android-*-unsigned.apk \
"$RUNNER_TEMP/attestation-subjects/"
cp "$RUNNER_TEMP/release-a/bitchat-android-release-unsigned.aab" \
"$RUNNER_TEMP/attestation-subjects/"
- name: Attest verified unsigned release
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
with:
subject-path: ${{ runner.temp }}/attestation-subjects/*
- name: Upload verified unsigned release
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
@ -89,65 +117,3 @@ jobs:
path: ${{ runner.temp }}/release-a/*
retention-days: 30
if-no-files-found: error
sign-and-release:
name: Sign, attest, and publish
runs-on: ubuntu-24.04
needs: compare
permissions:
attestations: write
contents: write
id-token: write
steps:
- name: Checkout tagged source
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
ref: ${{ env.RELEASE_TAG }}
- name: Set up pinned JDK
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}
- name: Download verified unsigned release
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: verified-unsigned-release
path: release
- name: Install checksum-verified Android Build Tools
run: tools/reproducible-builds/install-android-build-tools.sh "$RUNNER_TEMP/android-sdk"
- name: Deterministically sign verified GitHub APKs
env:
ANDROID_SDK_ROOT: ${{ runner.temp }}/android-sdk
BITCHAT_SIGNING_KEY_BASE64: ${{ secrets.SIGNING_KEY }}
BITCHAT_SIGNING_KEY_ALIAS: ${{ secrets.ALIAS }}
BITCHAT_KEYSTORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }}
BITCHAT_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: tools/reproducible-builds/sign-release.sh release
- name: Prepare public manifest names
run: |
mv release/BUILDINFO.json release/BITCHAT_BUILDINFO.json
mv release/SHA256SUMS.unsigned release/BITCHAT_SHA256SUMS.unsigned
sed \
-e 's/ BUILDINFO.json$/ BITCHAT_BUILDINFO.json/' \
-e 's/ SHA256SUMS.unsigned$/ BITCHAT_SHA256SUMS.unsigned/' \
release/SHA256SUMS > release/BITCHAT_SHA256SUMS
rm release/SHA256SUMS
- name: Attest release provenance
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
with:
subject-path: release/*
- name: Publish GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$RELEASE_TAG" release/* \
--title "Release $RELEASE_TAG" \
--notes "Signed installable APKs, canonical unsigned APK/AAB inputs, checksums, and GitHub build-provenance attestations are attached. See docs/reproducible-builds.md for verification instructions."

View File

@ -2,13 +2,13 @@
Bitchat's canonical release build produces byte-for-byte reproducible unsigned
APKs and an unsigned Android App Bundle (AAB). CI builds the release twice in
independent jobs and refuses to publish it unless every canonical byte matches.
independent jobs and exposes a verified release artifact only when every
canonical byte matches.
The signing key is intentionally not public. Anyone can reproduce the unsigned
artifacts; GitHub Actions signs only the already-verified APKs, checks that
signing itself is deterministic, verifies the expected certificate, and
publishes checksums and provenance attestations for both unsigned and signed
artifacts.
Signing remains local: no keystore or signing password is stored in or exposed
to GitHub Actions. Anyone can reproduce the unsigned artifacts; maintainers
download the verified CI output, sign the selected GitHub APKs and Play upload
AAB locally, then manually publish those exact files.
## What is pinned
@ -97,7 +97,8 @@ tools/reproducible-builds/verify-github-release.sh vX.Y.Z
That command:
1. downloads all release APKs, the AAB, build information, and checksum files;
2. verifies each GitHub artifact-attestation subject against this repository;
2. verifies the canonical unsigned build's GitHub artifact-attestation subjects
against this repository;
3. verifies `BITCHAT_SHA256SUMS`;
4. checks that the local source commit is the release commit;
5. rebuilds in the pinned container; and
@ -137,7 +138,7 @@ GitHub's manual equivalents are:
```bash
gh release download vX.Y.Z
sha256sum -c BITCHAT_SHA256SUMS
gh attestation verify bitchat-android-universal.apk \
gh attestation verify bitchat-android-universal-unsigned.apk \
--repo permissionlesstech/bitchat-android
```
@ -195,8 +196,9 @@ maintainers to retain the uploaded AAB, publish its digest and provenance, and
record the Play version code and app-signing certificate fingerprint alongside
the release.
The current GitHub release workflow publishes the canonical unsigned AAB for
verification; it does not upload to Google Play.
The GitHub workflow builds and attests the canonical unsigned AAB. A maintainer
locally creates `bitchat-android-play-upload.aab` from that exact file and
uploads it manually to Google Play.
## Maintainer release process
@ -204,15 +206,101 @@ Pushing a `vX.Y.Z` tag runs `.github/workflows/release.yml`:
1. two jobs independently build the canonical unsigned release;
2. a comparison job requires exact byte equality;
3. the signing job reads `SIGNING_KEY`, `ALIAS`, `KEY_STORE_PASSWORD`, and
`KEY_PASSWORD` from GitHub Actions secrets;
4. each published GitHub APK is signed twice and the two results must match;
5. the signing certificate must match the public fingerprint in
`gradle.properties`;
6. GitHub generates build-provenance attestations and publishes all artifacts.
3. GitHub generates provenance attestations for the canonical unsigned
artifacts; and
4. Actions uploads `verified-unsigned-release` for the maintainer to download.
Never store a keystore or password in the repository, workflow artifacts, logs,
or build information.
The workflow has read-only repository access and no signing secrets. It does not
create a GitHub Release or upload to Google Play.
To rerun an existing tag manually, dispatch the workflow against the tag ref
(not the default branch):
```bash
gh workflow run release.yml \
--repo permissionlesstech/bitchat-android \
--ref vX.Y.Z \
-f tag=vX.Y.Z
```
The workflow rejects a dispatch whose selected ref and requested tag resolve to
different commits, keeping the GitHub provenance tied to the source actually
built.
### 1. Download the verified build
Find the completed tag workflow's run ID, then download the promoted replica:
```bash
gh run download RUN_ID \
--repo permissionlesstech/bitchat-android \
--name verified-unsigned-release \
--dir release
```
Verify `release/SHA256SUMS.unsigned` before signing.
### 2. Sign GitHub APKs locally
Install Android Build Tools 37.0.0 and set these local environment variables:
- `ANDROID_SDK_ROOT`
- `BITCHAT_GITHUB_KEYSTORE`
- `BITCHAT_GITHUB_KEY_ALIAS`
- `BITCHAT_GITHUB_KEYSTORE_PASSWORD`
- `BITCHAT_GITHUB_KEY_PASSWORD`
Then run:
```bash
tools/reproducible-builds/sign-release.sh release
```
The helper signs the arm64, x86_64, and universal APKs twice, requires the two
signed results to match, and verifies their certificate against
`BITCHAT_GITHUB_RELEASE_CERT_SHA256` in `gradle.properties`.
### 3. Sign the Play AAB locally
Use JDK 21 and the Play upload key—not the Play app-signing key managed by
Google. Set:
- `JAVA_HOME`
- `BITCHAT_PLAY_UPLOAD_KEYSTORE`
- `BITCHAT_PLAY_UPLOAD_KEY_ALIAS`
- `BITCHAT_PLAY_KEYSTORE_PASSWORD`
- `BITCHAT_PLAY_KEY_PASSWORD`
Then run:
```bash
tools/reproducible-builds/sign-play-bundle.sh release
```
This creates `release/bitchat-android-play-upload.aab`, verifies its JAR
signature, proves that its non-signature payload matches the canonical unsigned
AAB, and updates `SHA256SUMS`. Upload that exact signed AAB to Play Console; do
not rebuild it in Android Studio.
### 4. Prepare and publish the GitHub Release
After both local signing steps:
```bash
tools/reproducible-builds/prepare-github-release.sh release
gh release create vX.Y.Z release/* \
--repo permissionlesstech/bitchat-android \
--verify-tag \
--draft \
--title "Release vX.Y.Z" \
--notes "See docs/reproducible-builds.md for verification instructions."
```
Review the draft assets and Play internal-track result before publishing the
GitHub Release and promoting the Play rollout.
Never store a keystore or password in the repository, GitHub Actions secrets,
workflow artifacts, logs, release notes, or build information.
## Updating dependencies or toolchains

View File

@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
RELEASE_DIR="${1:?usage: prepare-github-release.sh RELEASE_DIR}"
if [ ! -d "$RELEASE_DIR" ]; then
echo "error: release directory not found" >&2
exit 1
fi
RELEASE_DIR="$(cd "$RELEASE_DIR" && pwd)"
if command -v sha256sum >/dev/null 2>&1; then
SHA256=(sha256sum)
elif command -v shasum >/dev/null 2>&1; then
SHA256=(shasum -a 256)
else
echo "error: sha256sum or shasum is required" >&2
exit 1
fi
required=(
BUILDINFO.json
SHA256SUMS
SHA256SUMS.unsigned
bitchat-android-arm64.apk
bitchat-android-play-upload.aab
bitchat-android-universal.apk
bitchat-android-x86_64.apk
)
for artifact in "${required[@]}"; do
if [ ! -f "$RELEASE_DIR/$artifact" ]; then
echo "error: required release artifact missing: $artifact" >&2
exit 1
fi
done
for destination in BITCHAT_BUILDINFO.json BITCHAT_SHA256SUMS BITCHAT_SHA256SUMS.unsigned; do
if [ -e "$RELEASE_DIR/$destination" ]; then
echo "error: public release manifest already exists: $destination" >&2
exit 1
fi
done
(
cd "$RELEASE_DIR"
"${SHA256[@]}" -c SHA256SUMS
)
mv "$RELEASE_DIR/BUILDINFO.json" "$RELEASE_DIR/BITCHAT_BUILDINFO.json"
mv "$RELEASE_DIR/SHA256SUMS.unsigned" "$RELEASE_DIR/BITCHAT_SHA256SUMS.unsigned"
sed \
-e 's/ BUILDINFO.json$/ BITCHAT_BUILDINFO.json/' \
-e 's/ SHA256SUMS.unsigned$/ BITCHAT_SHA256SUMS.unsigned/' \
"$RELEASE_DIR/SHA256SUMS" > "$RELEASE_DIR/BITCHAT_SHA256SUMS"
rm "$RELEASE_DIR/SHA256SUMS"
(
cd "$RELEASE_DIR"
"${SHA256[@]}" -c BITCHAT_SHA256SUMS
)
echo "Release assets are checksummed and ready for manual GitHub publication."

View File

@ -0,0 +1,89 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RELEASE_DIR="${1:?usage: sign-play-bundle.sh RELEASE_DIR}"
: "${BITCHAT_PLAY_UPLOAD_KEYSTORE:?BITCHAT_PLAY_UPLOAD_KEYSTORE is required}"
: "${BITCHAT_PLAY_UPLOAD_KEY_ALIAS:?BITCHAT_PLAY_UPLOAD_KEY_ALIAS is required}"
: "${BITCHAT_PLAY_KEYSTORE_PASSWORD:?BITCHAT_PLAY_KEYSTORE_PASSWORD is required}"
: "${BITCHAT_PLAY_KEY_PASSWORD:?BITCHAT_PLAY_KEY_PASSWORD is required}"
if [ ! -d "$RELEASE_DIR" ]; then
echo "error: release directory not found" >&2
exit 1
fi
RELEASE_DIR="$(cd "$RELEASE_DIR" && pwd)"
if [ ! -f "$BITCHAT_PLAY_UPLOAD_KEYSTORE" ]; then
echo "error: Play upload keystore not found" >&2
exit 1
fi
KEYSTORE="$(
cd "$(dirname "$BITCHAT_PLAY_UPLOAD_KEYSTORE")"
printf '%s/%s\n' "$PWD" "$(basename "$BITCHAT_PLAY_UPLOAD_KEYSTORE")"
)"
if [ -n "${JAVA_HOME:-}" ] && [ -x "$JAVA_HOME/bin/jarsigner" ]; then
JARSIGNER="$JAVA_HOME/bin/jarsigner"
elif command -v jarsigner >/dev/null 2>&1; then
JARSIGNER="$(command -v jarsigner)"
else
echo "error: jarsigner is required; set JAVA_HOME to the pinned JDK" >&2
exit 1
fi
if command -v sha256sum >/dev/null 2>&1; then
SHA256=(sha256sum)
elif command -v shasum >/dev/null 2>&1; then
SHA256=(shasum -a 256)
else
echo "error: sha256sum or shasum is required" >&2
exit 1
fi
(
cd "$RELEASE_DIR"
"${SHA256[@]}" -c SHA256SUMS.unsigned
)
UNSIGNED_AAB="$RELEASE_DIR/bitchat-android-release-unsigned.aab"
SIGNED_AAB="$RELEASE_DIR/bitchat-android-play-upload.aab"
if [ -e "$SIGNED_AAB" ]; then
echo "error: signed Play upload AAB already exists" >&2
exit 1
fi
TEMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TEMP_DIR"' EXIT
STOREPASS_FILE="$TEMP_DIR/storepass"
KEYPASS_FILE="$TEMP_DIR/keypass"
printf '%s\n' "$BITCHAT_PLAY_KEYSTORE_PASSWORD" > "$STOREPASS_FILE"
printf '%s\n' "$BITCHAT_PLAY_KEY_PASSWORD" > "$KEYPASS_FILE"
chmod 600 "$STOREPASS_FILE" "$KEYPASS_FILE"
"$JARSIGNER" \
-keystore "$KEYSTORE" \
-storepass:file "$STOREPASS_FILE" \
-keypass:file "$KEYPASS_FILE" \
-digestalg SHA-256 \
-signedjar "$SIGNED_AAB" \
"$UNSIGNED_AAB" \
"$BITCHAT_PLAY_UPLOAD_KEY_ALIAS"
"$JARSIGNER" -verify "$SIGNED_AAB" >/dev/null
"$SCRIPT_DIR/compare-archive-payloads.sh" "$UNSIGNED_AAB" "$SIGNED_AAB"
(
cd "$RELEASE_DIR"
{
for artifact in *; do
[ "$artifact" = "SHA256SUMS" ] && continue
[ -f "$artifact" ] || continue
"${SHA256[@]}" "$artifact"
done
} | sort -k2 > SHA256SUMS
)
echo "Verified unsigned AAB was signed locally with the Play upload key and checksummed."

View File

@ -9,10 +9,19 @@ RELEASE_DIR="${1:?usage: sign-release.sh RELEASE_DIR}"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/TOOLCHAIN.env"
: "${BITCHAT_SIGNING_KEY_BASE64:?BITCHAT_SIGNING_KEY_BASE64 is required}"
: "${BITCHAT_SIGNING_KEY_ALIAS:?BITCHAT_SIGNING_KEY_ALIAS is required}"
: "${BITCHAT_KEYSTORE_PASSWORD:?BITCHAT_KEYSTORE_PASSWORD is required}"
: "${BITCHAT_KEY_PASSWORD:?BITCHAT_KEY_PASSWORD is required}"
: "${BITCHAT_GITHUB_KEYSTORE:?BITCHAT_GITHUB_KEYSTORE is required}"
: "${BITCHAT_GITHUB_KEY_ALIAS:?BITCHAT_GITHUB_KEY_ALIAS is required}"
: "${BITCHAT_GITHUB_KEYSTORE_PASSWORD:?BITCHAT_GITHUB_KEYSTORE_PASSWORD is required}"
: "${BITCHAT_GITHUB_KEY_PASSWORD:?BITCHAT_GITHUB_KEY_PASSWORD is required}"
if [ ! -f "$BITCHAT_GITHUB_KEYSTORE" ]; then
echo "error: GitHub release keystore not found" >&2
exit 1
fi
KEYSTORE="$(
cd "$(dirname "$BITCHAT_GITHUB_KEYSTORE")"
printf '%s/%s\n' "$PWD" "$(basename "$BITCHAT_GITHUB_KEYSTORE")"
)"
ANDROID_SDK_HOME="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
if [ -z "$ANDROID_SDK_HOME" ]; then
@ -27,9 +36,18 @@ if [ ! -x "$APKSIGNER" ] || [ ! -x "$ZIPALIGN" ]; then
exit 1
fi
if command -v sha256sum >/dev/null 2>&1; then
SHA256=(sha256sum)
elif command -v shasum >/dev/null 2>&1; then
SHA256=(shasum -a 256)
else
echo "error: sha256sum or shasum is required" >&2
exit 1
fi
(
cd "$RELEASE_DIR"
sha256sum -c SHA256SUMS.unsigned
"${SHA256[@]}" -c SHA256SUMS.unsigned
)
EXPECTED_CERT_SHA256="$(
@ -44,17 +62,8 @@ fi
TEMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TEMP_DIR"' EXIT
KEYSTORE="$TEMP_DIR/release.jks"
if base64 --help 2>&1 | grep -q -- '--decode'; then
printf '%s' "$BITCHAT_SIGNING_KEY_BASE64" | base64 --decode > "$KEYSTORE"
else
printf '%s' "$BITCHAT_SIGNING_KEY_BASE64" | base64 -D > "$KEYSTORE"
fi
chmod 600 "$KEYSTORE"
export BITCHAT_KEYSTORE_PASSWORD
export BITCHAT_KEY_PASSWORD
export BITCHAT_GITHUB_KEYSTORE_PASSWORD
export BITCHAT_GITHUB_KEY_PASSWORD
declare -A signed_names=(
["bitchat-android-arm64-unsigned.apk"]="bitchat-android-arm64.apk"
@ -68,14 +77,18 @@ for unsigned_name in "${!signed_names[@]}"; do
first_signed="$TEMP_DIR/first.apk"
second_signed="$TEMP_DIR/second.apk"
if [ -e "$signed_apk" ]; then
echo "error: signed APK already exists: ${signed_names[$unsigned_name]}" >&2
exit 1
fi
"$ZIPALIGN" -c -P 16 4 "$unsigned_apk"
for output_apk in "$first_signed" "$second_signed"; do
"$APKSIGNER" sign \
--ks "$KEYSTORE" \
--ks-key-alias "$BITCHAT_SIGNING_KEY_ALIAS" \
--ks-pass env:BITCHAT_KEYSTORE_PASSWORD \
--key-pass env:BITCHAT_KEY_PASSWORD \
--ks-key-alias "$BITCHAT_GITHUB_KEY_ALIAS" \
--ks-pass env:BITCHAT_GITHUB_KEYSTORE_PASSWORD \
--key-pass env:BITCHAT_GITHUB_KEY_PASSWORD \
--deterministic-dsa-signing true \
--min-sdk-version 26 \
--v1-signing-enabled false \
@ -113,9 +126,9 @@ done
for artifact in *; do
[ "$artifact" = "SHA256SUMS" ] && continue
[ -f "$artifact" ] || continue
sha256sum "$artifact"
"${SHA256[@]}" "$artifact"
done
} | sort -k2 > SHA256SUMS
)
echo "Verified unsigned APKs were deterministically signed and checksummed."
echo "Verified unsigned APKs were deterministically signed locally and checksummed."

View File

@ -43,10 +43,24 @@ gh release download "$TAG" \
--pattern 'bitchat-android-*.apk' \
--pattern 'bitchat-android-*.aab'
for artifact in "$DOWNLOAD_DIR"/*; do
gh attestation verify "$artifact" --repo "$REPOSITORY" >/dev/null
attested_artifacts=(
BITCHAT_BUILDINFO.json
BITCHAT_SHA256SUMS.unsigned
bitchat-android-arm64-unsigned.apk
bitchat-android-armv7-unsigned.apk
bitchat-android-release-unsigned.aab
bitchat-android-universal-unsigned.apk
bitchat-android-x86-unsigned.apk
bitchat-android-x86_64-unsigned.apk
)
for artifact in "${attested_artifacts[@]}"; do
if [ ! -f "$DOWNLOAD_DIR/$artifact" ]; then
echo "error: attested canonical artifact missing: $artifact" >&2
exit 1
fi
gh attestation verify "$DOWNLOAD_DIR/$artifact" --repo "$REPOSITORY" >/dev/null
done
echo "GitHub provenance attestations verified."
echo "GitHub provenance attestations for the canonical unsigned build verified."
(
cd "$DOWNLOAD_DIR"