Signing with DroidDeck's own key: hand-over from the testkey, test builds and a four-package release

This commit is contained in:
The412Banner committed 2026-09-24 12:45:13 -04:00
1 parent 498a37db32
commit cd28b189a2
8 files changed
+453

No files matched your search

+24
View File
@@ -66,6 +66,18 @@ jobs:
# The pull request as it would be once merged onto main is what gets built. When it cannot
# be merged, the conflicting files and the conflicting hunks go into the run summary and the
# pull-request comment, and the job stops here: there is nothing coherent to build.
# DroidDeck's signing key lives only in GitHub environment secrets (see sign.yml). A key file
# committed by mistake would be in the history for good: stop at the first one. The AOSP
# testkey is public and meant to be here.
- name: No private key in the tree
run: |
set -euo pipefail
keys=$(git ls-files | grep -iE '\.(jks|keystore|p12|pfx|pk8|pem|key)$' | grep -vx 'keystore/testkey.p12' || true)
if [ -n "$keys" ]; then
echo "::error::a private key is committed - remove it from the history and make a new one: $keys"
exit 1
fi
- name: Merge onto main, or say what conflicts
if: github.event_name == 'pull_request_target'
run: |
@@ -480,3 +492,15 @@ jobs:
retention-days: 30
# An apk is a zip already; compressing it again buys nothing and costs seconds.
compression-level: 0
# The apk signed with DroidDeck's own key (tools/release), so a test build installs over a release
# and keeps its data. Main's builds sign straight away (signing-main); any other branch's wait for
# the owner's approval (signing-branch). A pull request's never: its code is not ours to sign, and
# the job that would hold the key never runs for it. The testkey apk above is unaffected.
sign:
needs: build
if: github.event_name != 'pull_request_target'
uses: ./.github/workflows/sign.yml
with:
environment: ${{ github.ref == 'refs/heads/main' && 'signing-main' || 'signing-branch' }}
variants: '["standard"]'
+66
View File
@@ -0,0 +1,66 @@
name: Release APKs
run-name: release ${{ inputs.version }} from main ${{ github.sha }}
# Every package of a release, signed with DroidDeck's own key: the standard app and the named ones
# in tools/release/variants.txt, one matrix job each. Nothing is rebuilt - the apk is the one the
# Build APK run of this exact main commit made (with its caches) and that was tested, so the
# release is those bytes. Waits for the owner's approval (the release environment); publishing the
# GitHub release stays a separate, manual step.
on:
workflow_dispatch:
inputs:
version:
description: Version for the file names, e.g. 0.1.7
required: true
permissions:
contents: read
actions: read
concurrency:
group: release
cancel-in-progress: false
jobs:
find:
name: find the main build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
outputs:
run_id: ${{ steps.find.outputs.run_id }}
variants: ${{ steps.find.outputs.variants }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- id: find
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
[[ $VERSION =~ ^[0-9A-Za-z._-]+$ ]] || { echo "::error::version '$VERSION': letters, digits, . _ - only"; exit 1; }
# This commit's Build APK run - a push or a dispatch on main, never a pull request's.
runs=$(gh run list -R "$GITHUB_REPOSITORY" --workflow build.yml --branch main --commit "$GITHUB_SHA" \
--status success --json databaseId,event,headSha -L 20)
id=$(jq -r --arg sha "$GITHUB_SHA" \
'[.[] | select(.headSha == $sha and (.event == "push" or .event == "workflow_dispatch"))][0].databaseId // empty' <<<"$runs")
if [ -z "$id" ]; then
echo "::error::main ${GITHUB_SHA::7} has no successful Build APK run yet: wait for it, then release"
exit 1
fi
variants=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)
echo "run_id=$id" >> "$GITHUB_OUTPUT"
echo "variants=$variants" >> "$GITHUB_OUTPUT"
echo "Signing Build APK run $id (main ${GITHUB_SHA::7}) as $VERSION: $variants" >> "$GITHUB_STEP_SUMMARY"
sign:
needs: find
uses: ./.github/workflows/sign.yml
with:
environment: release
variants: ${{ needs.find.outputs.variants }}
run_id: ${{ needs.find.outputs.run_id }}
label: ${{ inputs.version }}
artifact: droiddeck-release
+104
View File
@@ -0,0 +1,104 @@
name: Sign APKs
# Called, never run by itself: turns a "Build APK" apk into packages signed with DroidDeck's own key,
# one matrix job per package in tools/release/variants.txt (tools/release/sign-apk.sh does the work
# and checks it). The key lives only in the GitHub environment named by the caller:
# signing-main builds of main
# signing-branch builds of any other branch - waits for the owner's approval
# release release.yml - main only, waits for the owner's approval
# Pull requests never get here (build.yml skips this for pull_request_target), so code from a pull
# request can never be signed with the key or come near it.
on:
workflow_call:
inputs:
environment:
type: string
required: true
variants:
description: JSON array of names from tools/release/variants.txt
type: string
required: true
run_id:
description: The Build APK run whose apk to sign; empty for the calling run's own
type: string
default: ''
label:
description: Goes in the file names (a version); empty for <branch>-<short sha>
type: string
default: ''
artifact:
type: string
default: droiddeck-signed
permissions:
contents: read
actions: read
jobs:
sign:
name: sign ${{ matrix.variant }}
runs-on: ubuntu-24.04
timeout-minutes: 15
environment: ${{ inputs.environment }}
strategy:
fail-fast: false
matrix:
variant: ${{ fromJSON(inputs.variants) }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: The apk (this run's build)
if: inputs.run_id == ''
uses: actions/download-artifact@v4
with:
name: droiddeck-apk
path: in
- name: The apk (a main build)
if: inputs.run_id != ''
env:
GH_TOKEN: ${{ github.token }}
RUN: ${{ inputs.run_id }}
run: gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-apk -D in
- name: Sign
env:
RELEASE_KEYSTORE_B64: ${{ secrets.RELEASE_KEYSTORE_B64 }}
RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
ENVIRONMENT: ${{ inputs.environment }}
VARIANT: ${{ matrix.variant }}
LABEL: ${{ inputs.label }}
run: |
set -euo pipefail
if [ -z "$RELEASE_KEYSTORE_B64" ]; then
echo "::error::environment '$ENVIRONMENT' has no signing key: run tools/release/make-release-key.sh once"
exit 1
fi
export RELEASE_KEYSTORE="$RUNNER_TEMP/release.p12"
( umask 077; base64 -d <<<"$RELEASE_KEYSTORE_B64" > "$RELEASE_KEYSTORE" )
BUILD_TOOLS=$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -n1)
export BUILD_TOOLS
[ -n "$LABEL" ] || LABEL="${GITHUB_REF_NAME//\//-}-${GITHUB_SHA::7}"
suffix=$(awk -v v="$VARIANT" '!/^#/ && $1 == v { print $3 }' tools/release/variants.txt)
[ "$suffix" = - ] && suffix=
apks=(in/*.apk)
[ ${#apks[@]} = 1 ] && [ -f "${apks[0]}" ] || { echo "::error::expected one apk in the build"; exit 1; }
mkdir -p out
out="out/DroidDeck-$LABEL$suffix.apk"
tools/release/sign-apk.sh "${apks[0]}" "$VARIANT" "$out"
( cd out && sha256sum -- *.apk > "SHA256SUMS-$VARIANT.txt" && cat "SHA256SUMS-$VARIANT.txt" )
echo "- \`$(basename "$out")\` - $(cut -c1-16 "out/SHA256SUMS-$VARIANT.txt")…" >> "$GITHUB_STEP_SUMMARY"
- name: Forget the key
if: always()
run: rm -f "$RUNNER_TEMP/release.p12"
- uses: actions/upload-artifact@v4
with:
name: ${{ inputs.artifact }}-${{ matrix.variant }}
path: out/*
if-no-files-found: error
+12
View File
@@ -14,3 +14,15 @@ app/src/main/assets/linuxfs/
# Kotlin compiler session files (build-tool state, never source).
.kotlin/
# Signing keys never enter the repo (DroidDeck's lives in GitHub environment secrets; see
# tools/release/make-release-key.sh). keystore/testkey.p12 is the public AOSP testkey.
*.jks
*.keystore
*.p12
*.pfx
*.pk8
*.pem
!keystore/testkey.p12
droiddeck-release-key/
password.txt
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
# axml_rename.py in.apk out.apk old=new ... : rewrite whole strings in AndroidManifest.xml's string
# pool (binary XML), leaving every other entry of the apk byte-identical. Unsigned output.
import struct, sys, zipfile
def dec_len8(b, o):
n = b[o]; o += 1
if n & 0x80: n = ((n & 0x7f) << 8) | b[o]; o += 1
return n, o
def dec_len16(b, o):
n = struct.unpack_from('<H', b, o)[0]; o += 2
if n & 0x8000: n = ((n & 0x7fff) << 16) | struct.unpack_from('<H', b, o)[0]; o += 2
return n, o
def enc_len8(n):
return bytes([n]) if n < 0x80 else bytes([0x80 | (n >> 8), n & 0xff])
def enc_len16(n):
return struct.pack('<H', n) if n < 0x8000 else struct.pack('<HH', 0x8000 | (n >> 16), n & 0xffff)
def rewrite(axml, repl):
ftype, fhs, fsize = struct.unpack_from('<HHI', axml, 0)
assert ftype == 0x0003, 'not binary XML'
sp = fhs
ctype, chs, csize, count, scount, flags, sstart, stystart = struct.unpack_from('<HHIIIIII', axml, sp)
assert ctype == 0x0001
utf8 = bool(flags & 0x100)
offs = struct.unpack_from('<%dI' % count, axml, sp + chs)
sty_offs = axml[sp + chs + 4 * count: sp + chs + 4 * count + 4 * scount]
base = sp + sstart
strings = []
for off in offs:
o = base + off
if utf8:
_, o = dec_len8(axml, o); n, o = dec_len8(axml, o)
strings.append(axml[o:o + n].decode('utf-8'))
else:
n, o = dec_len16(axml, o)
strings.append(axml[o:o + 2 * n].decode('utf-16-le'))
hits = 0
for i, s in enumerate(strings):
if s in repl: strings[i] = repl[s]; hits += 1
data = bytearray(); new_offs = []
for s in strings:
new_offs.append(len(data))
if utf8:
e = s.encode('utf-8'); data += enc_len8(len(s.encode('utf-16-le')) // 2) + enc_len8(len(e)) + e + b'\0'
else:
e = s.encode('utf-16-le'); data += enc_len16(len(e) // 2) + e + b'\0\0'
while len(data) % 4: data += b'\0'
styles = axml[sp + stystart: sp + csize] if scount else b''
new_sstart = chs + 4 * count + 4 * scount
new_stystart = new_sstart + len(data) if scount else 0
body = struct.pack('<%dI' % count, *new_offs) + sty_offs + bytes(data) + styles
new_csize = chs + len(body)
header = struct.pack('<HHIIIIII', ctype, chs, new_csize, count, scount, flags, new_sstart, new_stystart)
header += axml[sp + 28: sp + chs]
rest = axml[sp + csize:]
out = axml[:sp] + header + body + rest
out = out[:4] + struct.pack('<I', len(out)) + out[8:]
return out, hits
src, dst = sys.argv[1], sys.argv[2]
repl = dict(a.split('=', 1) for a in sys.argv[3:])
with zipfile.ZipFile(src) as zi, zipfile.ZipFile(dst, 'w') as zo:
for info in zi.infolist():
if info.filename.startswith('META-INF/') and info.filename.split('/')[-1].split('.')[-1] in ('SF', 'RSA', 'DSA', 'EC', 'MF'):
continue
b = zi.read(info)
if info.filename == 'AndroidManifest.xml':
b, hits = rewrite(b, repl)
print(f'manifest: {hits} strings replaced')
zo.writestr(info, b, compress_type=info.compress_type)
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# make-release-key.sh - run ONCE, by the owner. Makes DroidDeck's signing key and gives it to GitHub.
#
# - The key is made on this device, in ~/droiddeck-release-key (outside the repo), with a long
# random password written beside it. Nothing secret is printed.
# - The key and password go to GitHub as secrets of the three signing environments
# (signing-main, signing-branch, release). GitHub never shows a secret again.
# - The key's certificate digest - public, not a secret - goes to keystore/release-signer.sha256,
# which CI checks every signed apk against. That file is the only thing to commit.
#
# BACK UP ~/droiddeck-release-key (both files) somewhere safe - a password manager, a USB stick.
# Without it no update can ever be signed for the people who installed a signed build.
set -euo pipefail
REPO=The412Banner/DroidDeck
ENVIRONMENTS=(signing-main signing-branch release)
ALIAS=droiddeck
OUT=${DROIDDECK_KEY_DIR:-$HOME/droiddeck-release-key}
here=$(cd "$(dirname "$0")" && pwd)
repo=$(cd "$here/../.." && pwd)
die() { echo "error: $*" >&2; exit 1; }
command -v keytool >/dev/null || die "keytool not found (Termux: pkg install openjdk-17)"
command -v gh >/dev/null || die "gh not found"
gh auth status >/dev/null 2>&1 || die "gh is not logged in (gh auth login)"
[ -e "$OUT/droiddeck-release.p12" ] && die "a key already exists in $OUT - there must only ever be one"
case "$OUT" in "$repo"|"$repo"/*) die "the key must not be made inside the repo" ;; esac
for env in "${ENVIRONMENTS[@]}"; do
gh api "repos/$REPO/environments/$env" >/dev/null 2>&1 || die "GitHub environment '$env' does not exist yet"
done
umask 077
mkdir -p "$OUT"
KS="$OUT/droiddeck-release.p12"
# 48 random characters; PKCS12 uses one password for the store and the key.
KS_PW=$(head -c 60 /dev/urandom | base64 -w0 | tr -d '/+=')
KS_PW=${KS_PW:0:48}
[ ${#KS_PW} -ge 40 ] || die "could not make a password"
export KS_PW
printf '%s\n' "$KS_PW" > "$OUT/password.txt"
keytool -genkeypair -keystore "$KS" -storetype PKCS12 -alias "$ALIAS" \
-keyalg RSA -keysize 4096 -validity 10950 -dname "CN=DroidDeck, O=The412Banner" \
-storepass:env KS_PW -keypass:env KS_PW >/dev/null 2>&1 || die "keytool could not make the key"
listing=$(keytool -list -v -keystore "$KS" -storepass:env KS_PW -alias "$ALIAS")
SHA=$(awk '/SHA256:/ { print $2 }' <<<"$listing" | head -n1 | tr -d ':' | tr 'A-F' 'a-f')
[[ $SHA =~ ^[0-9a-f]{64}$ ]] || die "could not read the key's certificate digest"
for env in "${ENVIRONMENTS[@]}"; do
base64 -w0 < "$KS" | gh secret set RELEASE_KEYSTORE_B64 --env "$env" -R "$REPO" >/dev/null
printf '%s' "$KS_PW" | gh secret set RELEASE_STORE_PASSWORD --env "$env" -R "$REPO" >/dev/null
printf '%s' "$ALIAS" | gh secret set RELEASE_KEY_ALIAS --env "$env" -R "$REPO" >/dev/null
echo "secrets set for environment: $env"
done
unset KS_PW
printf '%s\n' "$SHA" > "$repo/keystore/release-signer.sha256"
cat <<EOF
DroidDeck's signing key is made and in GitHub.
Key: $KS
Password: $OUT/password.txt
Digest: $SHA (public - written to keystore/release-signer.sha256)
Now:
1. Back up the folder $OUT (both files) to at least two safe places.
2. Commit keystore/release-signer.sha256 (only that file).
EOF
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# sign-apk.sh <in.apk> <variant> <out.apk>
#
# Turns a CI build (signed with the public AOSP testkey) into one package of tools/release/variants.txt
# signed with DroidDeck's own key, carrying the hand-over from the testkey:
# - v1 + v2 are signed by the testkey, v3 by our key, with a signed lineage testkey -> our key;
# - an install from before (testkey) updates to it and keeps its data;
# - the testkey loses its rollback right, so an apk signed with the testkey alone - which anyone
# can make - is refused as an update to it (device-proven on the Pocket FIT, 2026-09-24);
# - --rotation-min-sdk-version 28: the lineage applies from Android 9, not only from 13.
#
# The key comes from the environment and is never written anywhere but the path given:
# RELEASE_KEYSTORE path to the PKCS12 keystore
# RELEASE_STORE_PASSWORD its password (the key has the same one: PKCS12)
# RELEASE_KEY_ALIAS the key's alias
# BUILD_TOOLS Android build-tools dir (zipalign, apksigner, aapt)
# RELEASE_SIGNER_SHA256 optional: the expected certificate digest, else keystore/release-signer.sha256
set -euo pipefail
die() { echo "::error::$*" >&2; exit 1; }
[ $# -eq 3 ] || die "usage: sign-apk.sh <in.apk> <variant> <out.apk>"
in=$1 variant=$2 out=$3
here=$(cd "$(dirname "$0")" && pwd)
repo=$(cd "$here/../.." && pwd)
pkg=$(awk -v v="$variant" '!/^#/ && $1 == v { print $2 }' "$here/variants.txt")
[ -n "$pkg" ] || die "no variant '$variant' in tools/release/variants.txt"
expected=${RELEASE_SIGNER_SHA256:-$(tr -d ' \n' < "$repo/keystore/release-signer.sha256" 2>/dev/null || true)}
[[ $expected =~ ^[0-9a-f]{64}$ ]] \
|| die "keystore/release-signer.sha256 is missing: make the key first (tools/release/make-release-key.sh)"
: "${RELEASE_KEYSTORE:?}" "${RELEASE_STORE_PASSWORD:?}" "${RELEASE_KEY_ALIAS:?}" "${BUILD_TOOLS:?}"
[ -s "$RELEASE_KEYSTORE" ] || die "the release keystore is empty - is the secret set for this environment?"
TESTKEY="$repo/keystore/testkey.p12"
TESTKEY_SHA256=a40da80a59d170caa950cf15c18c454d47a39b26989d8b640ecd745ba71bf5dc
export TESTKEY_PASSWORD=android
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
# The package name and everything the manifest derives from it; component class names stay.
P=com.droiddeck.launcher
if [ "$pkg" = "$P" ]; then
python3 "$here/axml_rename.py" "$in" "$work/renamed.apk"
else
python3 "$here/axml_rename.py" "$in" "$work/renamed.apk" \
"$P=$pkg" "$P.androidx-startup=$pkg.androidx-startup" "$P.logs=$pkg.logs" \
"$P.documents=$pkg.documents" "$P.home=$pkg.home" \
"$P.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION=$pkg.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"
fi
got=$("$BUILD_TOOLS/aapt" dump badging "$work/renamed.apk" | sed -n "s/^package: name='\([^']*\)'.*/\1/p")
[ "$got" = "$pkg" ] || die "$variant: the apk says package '$got', not '$pkg'"
if [ "$pkg" != "$P" ]; then
# A provider left on the old authority would clash with the standard app installed beside it.
tree=$("$BUILD_TOOLS/aapt" dump xmltree "$work/renamed.apk" AndroidManifest.xml)
grep -E 'android:(authorities|taskAffinity)' <<<"$tree" | grep -F "\"$P." \
&& die "$variant: the manifest still names $P in an authority or task"
fi
"$BUILD_TOOLS/zipalign" -p -f 4 "$work/renamed.apk" "$work/aligned.apk"
# The lineage: the testkey vouches for our key. Made fresh each time (it is two certificates and
# a signature, nothing secret beyond the key that signs it).
"$BUILD_TOOLS/apksigner" rotate --out "$work/lineage" \
--old-signer --ks "$TESTKEY" --ks-type PKCS12 --ks-pass env:TESTKEY_PASSWORD \
--ks-key-alias testkey --key-pass env:TESTKEY_PASSWORD --set-rollback false \
--new-signer --ks "$RELEASE_KEYSTORE" --ks-type PKCS12 --ks-pass env:RELEASE_STORE_PASSWORD \
--ks-key-alias "$RELEASE_KEY_ALIAS" --key-pass env:RELEASE_STORE_PASSWORD
"$BUILD_TOOLS/apksigner" sign \
--ks "$TESTKEY" --ks-type PKCS12 --ks-pass env:TESTKEY_PASSWORD \
--ks-key-alias testkey --key-pass env:TESTKEY_PASSWORD \
--next-signer --ks "$RELEASE_KEYSTORE" --ks-type PKCS12 --ks-pass env:RELEASE_STORE_PASSWORD \
--ks-key-alias "$RELEASE_KEY_ALIAS" --key-pass env:RELEASE_STORE_PASSWORD \
--lineage "$work/lineage" --rotation-min-sdk-version 28 \
--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true --v4-signing-enabled false \
--out "$out" "$work/aligned.apk"
rm -f "$out.idsig"
# What the apk must carry, checked rather than assumed.
"$BUILD_TOOLS/apksigner" verify --min-sdk-version 26 "$out" >/dev/null || die "$variant: does not verify"
# (Listed first, then searched: grep -q stops reading early, which fails a pipe under pipefail.)
listing=$(unzip -l "$out")
grep -qE 'META-INF/.*\.(SF|RSA)$' <<<"$listing" || die "$variant: no JAR signature (old installers)"
certs=$("$BUILD_TOOLS/apksigner" verify --min-sdk-version 28 --print-certs "$out")
v3=$(sed -n 's/^Signer #1 certificate SHA-256 digest: //p' <<<"$certs")
[ "$v3" = "$expected" ] || die "$variant: Android 9+ sees signer $v3, not DroidDeck's key $expected"
lineage=$("$BUILD_TOOLS/apksigner" lineage --in "$out" --print-certs)
grep -q "Signer #1 in lineage certificate SHA-256 digest: $TESTKEY_SHA256" <<<"$lineage" \
|| die "$variant: the lineage does not start at the testkey - old installs could not update"
grep -q "Signer #2 in lineage certificate SHA-256 digest: $expected" <<<"$lineage" \
|| die "$variant: the lineage does not hand over to DroidDeck's key"
rollback=$(awk '/Signer #1 in lineage/{s=1} /Signer #2 in lineage/{s=0} s && /rollback capability/' <<<"$lineage")
grep -q false <<<"$rollback" \
|| die "$variant: the testkey kept its rollback right - a testkey-signed apk could replace this one"
echo "$variant ($pkg): signed, hand-over from the testkey, v3 signer $expected"
+9
View File
@@ -0,0 +1,9 @@
# The packages a signed build ships as. The same apk, with only the manifest's package name (and
# the names derived from it) changed: some phones give their performance modes only to apps with
# certain names. Each is its own app to Android - installs beside the others, with its own data.
#
# name package file suffix
standard com.droiddeck.launcher -
pubg com.tencent.ig -pubg
antutu com.antutu.benchmark.full -antutu
ludashi com.ludashi.benchmark -ludashi