ci: fail the build when a 64-bit native library is not 16 KB aligned (#20)

* ci: fail the build when a 64-bit native library is not 16 KB aligned

Play rejects updates whose 64-bit native libraries are not aligned for 16 KB memory
pages, but nothing in the build says so. A misaligned .so surfaces only as a policy
issue in Play Console, days after a release has gone out — which is how one reached
production here unnoticed.

The check reads what the build actually produced rather than trusting the dependency
list, because a single transitive dependency shipping an unaligned library is enough
to fail the whole bundle. It walks every .aab and .apk under app/build/outputs, pulls
each arm64-v8a and x86_64 .so, and compares its largest LOAD segment alignment against
16384. 32-bit ABIs are skipped: those devices use 4 KB pages.

Verified against the v1.2.0 release bundle before it was cleaned up: 14 libraries
scanned, and it caught libpl_droidsonroids_gif.so at 0x1000 — the one library Play
flagged. A check that cannot fail on known-bad input is not a check.

* test: make the tamper-detection test actually tamper

checkTamperedDataFails wrote to encoded[lastIndex - 1] but computed the new value from
encoded.last(). Whenever those two bytes already differed by exactly 0x01 the write was
a no-op, the ciphertext stayed valid, decryption succeeded, and the expected
AEADBadTagException never arrived — so the test failed for reasons unrelated to what it
checks. One run in 256.

Flip a bit in the byte being written instead, which changes the ciphertext every time.
This commit is contained in:
2026-08-03 07:25:46 -07:00
committed by GitHub
parent 39dd67ba94
commit d8b31739e7
2 changed files with 51 additions and 2 deletions
+45
View File
@@ -236,6 +236,51 @@ jobs:
if: ${{ inputs.build-debug-tests }}
run: ./gradlew assembleDebugAndroidTest
# Play rejects updates whose 64-bit native libraries are not aligned for 16 KB
# memory pages. Nothing in the build says so — a misaligned .so only surfaces as a
# policy issue in Play Console, days later, after a release has already gone out.
# One dependency shipping an unaligned library is enough, so check what was
# actually produced rather than trusting the dependency list.
#
# 32-bit ABIs are exempt: those devices use 4 KB pages.
- name: 🔍 Verify 16 KB native alignment
if: ${{ !startsWith(inputs.gradlew-command, 'false') }}
run: |
set -euo pipefail
work=$(mktemp -d)
found=0
bad=0
for archive in $(find app/build/outputs -name '*.aab' -o -name '*.apk' | sort); do
for entry in $(unzip -Z1 "$archive" 2>/dev/null | grep -E 'lib/(arm64-v8a|x86_64)/.*\.so$' || true); do
found=$((found + 1))
unzip -o -q -j "$archive" "$entry" -d "$work"
lib="$work/$(basename "$entry")"
# Largest LOAD segment alignment; 16384 (0x4000) is the requirement.
align=$(readelf -lW "$lib" 2>/dev/null | awk '/LOAD/ {print $NF}' | sort -u | tail -1)
align_dec=$((align))
if [ "$align_dec" -lt 16384 ]; then
echo "::error::$(basename "$archive") $entry aligned at $align, needs 0x4000 (16384)"
bad=$((bad + 1))
else
echo " ok $entry $align"
fi
rm -f "$lib"
done
done
rm -rf "$work"
if [ "$found" -eq 0 ]; then
echo "No 64-bit native libraries in the build output — nothing to check."
exit 0
fi
echo "Checked $found 64-bit native libraries, $bad misaligned."
[ "$bad" -eq 0 ]
- name: 🧹 Delete key after building
if: ${{ !startsWith(inputs.keystore-file-name, 'false') }}
run: rm ./app/${{ inputs.keystore-file-name }}
@@ -68,8 +68,12 @@ class ExchangeSecretsTest {
val encoded = keyExchangeUtils.encrypt("Hello".toByteArray(), peerA, peerB.public)
// Change one byte
encoded[encoded.lastIndex - 1] = (encoded.last() xor 0x01)
// Flip a bit in the byte being written, not in a different one: reading last()
// while writing lastIndex - 1 leaves the array untouched whenever those two
// bytes already differ by exactly 0x01, which is 1 run in 256. Decryption then
// succeeds, no AEADBadTagException is thrown, and a tamper-detection test
// reports a failure that has nothing to do with tamper detection.
encoded[encoded.lastIndex - 1] = (encoded[encoded.lastIndex - 1] xor 0x01)
keyExchangeUtils.decrypt(encoded, peerB, peerA.public)
}