mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Compare commits
9 Commits
v0.2.1
...
56d19014d4
| Author | SHA1 | Date | |
|---|---|---|---|
| 56d19014d4 | |||
| 51bf0abba2 | |||
| e0fb84ccb9 | |||
| 30025a4ebf | |||
| 50e9a6c1cc | |||
| 224a8e0e7a | |||
| efacfab213 | |||
|
|
0ec7618ce8 | ||
| 8b75423b7a |
11
.github/workflows/apple-release.yml
vendored
11
.github/workflows/apple-release.yml
vendored
@@ -132,7 +132,16 @@ jobs:
|
||||
echo "SPARKLE_ED_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build, sign & notarize DMG
|
||||
run: apple/scripts/build-dmg.sh
|
||||
run: make build-apple-dmg
|
||||
|
||||
- name: Upload notarization diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: vnidrop-${{ steps.version.outputs.app }}-notarization-diagnostics
|
||||
path: apple/dist/*.notary-log.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: Generate appcast
|
||||
env:
|
||||
|
||||
39
.github/workflows/release.yml
vendored
39
.github/workflows/release.yml
vendored
@@ -185,37 +185,19 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
apk="build/release/play/VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk"
|
||||
apksigner_path="$(
|
||||
find "$ANDROID_SDK_ROOT/build-tools" -type f -name apksigner -perm -111 |
|
||||
sort -r |
|
||||
head -1
|
||||
)"
|
||||
apkanalyzer_path="$(
|
||||
find "$ANDROID_SDK_ROOT/cmdline-tools" -type f -name apkanalyzer -perm -111 |
|
||||
sort -r |
|
||||
head -1
|
||||
)"
|
||||
if [ -z "$apksigner_path" ] || [ -z "$apkanalyzer_path" ]; then
|
||||
echo "Android SDK verification tools were not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
"$apksigner_path" verify --verbose --print-certs "$apk" \
|
||||
> build/release/play/apksigner-report.txt
|
||||
actual="$(
|
||||
awk -F': ' '/Signer #1 certificate SHA-256 digest:/ {print $2; exit}' \
|
||||
build/release/play/apksigner-report.txt |
|
||||
tr -d '[:space:]:' |
|
||||
tr '[:upper:]' '[:lower:]'
|
||||
)"
|
||||
expected="$(
|
||||
printf '%s' "$EXPECTED_CERT_SHA256" |
|
||||
tr -d '[:space:]:' |
|
||||
tr '[:upper:]' '[:lower:]'
|
||||
)"
|
||||
if [ -z "$actual" ] || [ "$actual" != "$expected" ]; then
|
||||
echo "Play APK signing certificate mismatch" >&2
|
||||
if [ -z "$apkanalyzer_path" ]; then
|
||||
echo "apkanalyzer was not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
packaging/android/verify-apk-signature.sh \
|
||||
"$apk" \
|
||||
"$EXPECTED_CERT_SHA256" \
|
||||
>/dev/null
|
||||
if [ "$("$apkanalyzer_path" manifest application-id "$apk")" != "com.vnidrop.app" ]; then
|
||||
echo "Play APK package name mismatch" >&2
|
||||
exit 1
|
||||
@@ -228,7 +210,6 @@ jobs:
|
||||
echo "Play APK version code mismatch" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm build/release/play/apksigner-report.txt
|
||||
(
|
||||
cd build/release/play
|
||||
sha256sum \
|
||||
@@ -314,10 +295,6 @@ jobs:
|
||||
SELLER_ID: ${{ secrets.SELLER_ID }}
|
||||
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
|
||||
run: |
|
||||
msstore settings --enableTelemetry false
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to disable Microsoft Store CLI telemetry"
|
||||
}
|
||||
msstore reconfigure `
|
||||
--tenantId "$env:AZURE_AD_TENANT_ID" `
|
||||
--sellerId "$env:SELLER_ID" `
|
||||
@@ -326,6 +303,10 @@ jobs:
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Microsoft Store authentication failed"
|
||||
}
|
||||
msstore settings --enableTelemetry false
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to disable Microsoft Store CLI telemetry"
|
||||
}
|
||||
msstore apps get "$env:MICROSOFT_STORE_PRODUCT_ID"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "The Microsoft Store application is not accessible"
|
||||
|
||||
8
Makefile
8
Makefile
@@ -71,8 +71,12 @@ check-version: ## Validate the canonical version and its platform mappings.
|
||||
cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS)
|
||||
|
||||
check-release: ## Validate coordinated release scripts and workflow YAML.
|
||||
cd $(ROOT) && bash -n packaging/android/build-release.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh
|
||||
cd $(ROOT) && bash -n apple/scripts/notarize.sh apple/scripts/sign-exported-app.sh apple/scripts/tests/test-notarize.sh apple/scripts/tests/test-sign-exported-app.sh packaging/android/build-release.sh packaging/android/verify-apk-signature.sh packaging/android/tests/test_verify_apk_signature.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh packaging/release/test-release-config.sh
|
||||
cd $(ROOT) && apple/scripts/tests/test-notarize.sh
|
||||
cd $(ROOT) && apple/scripts/tests/test-sign-exported-app.sh
|
||||
cd $(ROOT) && packaging/android/tests/test_verify_apk_signature.sh
|
||||
cd $(ROOT) && packaging/release/test-assemble-release.sh
|
||||
cd $(ROOT) && packaging/release/test-release-config.sh
|
||||
cd $(ROOT) && python3 -m unittest discover -s packaging/android/tests -v
|
||||
cd $(ROOT) && ruby -e 'require "yaml"; ARGV.each { |file| YAML.load_file(file) }' .github/workflows/*.yml
|
||||
|
||||
@@ -143,7 +147,7 @@ build-apple-macos: apple-project ## Build the native macOS app (unsigned by defa
|
||||
build-apple-macos-direct: apple-project ## Build the direct-download macOS target (Sparkle, unsigned) — CI compile check.
|
||||
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDropDirect -configuration Release-Direct -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build
|
||||
|
||||
build-apple-dmg: ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env).
|
||||
build-apple-dmg: localization ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env).
|
||||
cd $(ROOT) && apple/scripts/build-dmg.sh
|
||||
|
||||
open-apple: build-apple-macos ## Build and launch the native macOS app.
|
||||
|
||||
@@ -105,6 +105,12 @@ ACTUAL_BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' \
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "==> Enforcing hardened-runtime signature"
|
||||
"$SCRIPT_DIR/sign-exported-app.sh" \
|
||||
"$APP" \
|
||||
"$DEVELOPER_ID_APP" \
|
||||
"$APPLE_DIR/VniDrop/Resources/VniDropDirect.entitlements"
|
||||
|
||||
# --- Build the DMG -----------------------------------------------------------
|
||||
DMG="$DIST_DIR/$APP_NAME-$VERSION.dmg"
|
||||
rm -f "$DMG"
|
||||
@@ -137,7 +143,8 @@ codesign --force --sign "$DEVELOPER_ID_APP" --timestamp "$DMG"
|
||||
# --- Notarize + staple -------------------------------------------------------
|
||||
if [ -n "${NOTARY_PROFILE:-}" ]; then
|
||||
echo "==> Notarizing (profile: $NOTARY_PROFILE)"
|
||||
xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait
|
||||
NOTARY_LOG="$DIST_DIR/$APP_NAME-$VERSION.notary-log.json"
|
||||
"$SCRIPT_DIR/notarize.sh" "$DMG" "$NOTARY_PROFILE" "$NOTARY_LOG"
|
||||
echo "==> Stapling"
|
||||
xcrun stapler staple "$DMG"
|
||||
xcrun stapler validate "$DMG"
|
||||
|
||||
67
apple/scripts/notarize.sh
Executable file
67
apple/scripts/notarize.sh
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 3 ]]; then
|
||||
printf 'Usage: %s <artifact> <keychain-profile> <log-output>\n' "$0" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
artifact=$1
|
||||
keychain_profile=$2
|
||||
log_output=$3
|
||||
|
||||
[[ -s $artifact ]] || {
|
||||
printf 'error: notarization artifact is missing or empty: %s\n' "$artifact" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ -n $keychain_profile ]] || {
|
||||
printf 'error: notarization keychain profile is empty\n' >&2
|
||||
exit 1
|
||||
}
|
||||
[[ -n $log_output ]] || {
|
||||
printf 'error: notarization log output path is empty\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
rm -f "$log_output"
|
||||
set +e
|
||||
response="$(
|
||||
xcrun notarytool submit "$artifact" \
|
||||
--keychain-profile "$keychain_profile" \
|
||||
--wait \
|
||||
--output-format json
|
||||
)"
|
||||
submit_exit=$?
|
||||
set -e
|
||||
printf '%s\n' "$response"
|
||||
|
||||
submission_id="$(
|
||||
printf '%s\n' "$response" |
|
||||
jq -r '.id // empty' 2>/dev/null ||
|
||||
true
|
||||
)"
|
||||
status="$(
|
||||
printf '%s\n' "$response" |
|
||||
jq -r '.status // empty' 2>/dev/null ||
|
||||
true
|
||||
)"
|
||||
|
||||
if [[ $submit_exit -eq 0 && $status == Accepted && -n $submission_id ]]; then
|
||||
printf 'Notarization accepted (submission %s)\n' "$submission_id"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf 'error: notarization was not accepted (status: %s, submission: %s)\n' \
|
||||
"${status:-unknown}" "${submission_id:-unknown}" >&2
|
||||
if [[ -n $submission_id ]]; then
|
||||
mkdir -p "$(dirname "$log_output")"
|
||||
if xcrun notarytool log "$submission_id" "$log_output" \
|
||||
--keychain-profile "$keychain_profile"; then
|
||||
printf '%s\n' 'Apple notarization log:' >&2
|
||||
cat "$log_output" >&2
|
||||
else
|
||||
printf 'error: could not retrieve the Apple notarization log\n' >&2
|
||||
fi
|
||||
fi
|
||||
exit 1
|
||||
42
apple/scripts/sign-exported-app.sh
Executable file
42
apple/scripts/sign-exported-app.sh
Executable file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 3 ]]; then
|
||||
printf 'Usage: %s <app-bundle> <signing-identity> <entitlements>\n' "$0" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
app=$1
|
||||
signing_identity=$2
|
||||
entitlements=$3
|
||||
|
||||
[[ -d $app ]] || {
|
||||
printf 'error: exported app bundle does not exist: %s\n' "$app" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ -n $signing_identity ]] || {
|
||||
printf 'error: signing identity is empty\n' >&2
|
||||
exit 1
|
||||
}
|
||||
[[ -f $entitlements ]] || {
|
||||
printf 'error: entitlements file does not exist: %s\n' "$entitlements" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
codesign \
|
||||
--force \
|
||||
--sign "$signing_identity" \
|
||||
--options runtime \
|
||||
--timestamp \
|
||||
--entitlements "$entitlements" \
|
||||
"$app"
|
||||
codesign --verify --deep --strict --verbose=2 "$app"
|
||||
|
||||
signature_details="$(codesign --display --verbose=4 "$app" 2>&1)"
|
||||
printf '%s\n' "$signature_details"
|
||||
printf '%s\n' "$signature_details" |
|
||||
grep -Eq 'flags=.*\(runtime([^)]*)?\)' || {
|
||||
printf 'error: exported app signature does not enable the hardened runtime\n' >&2
|
||||
exit 1
|
||||
}
|
||||
87
apple/scripts/tests/test-notarize.sh
Executable file
87
apple/scripts/tests/test-notarize.sh
Executable file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
notarize="$script_dir/../notarize.sh"
|
||||
scratch="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-notarize-test.XXXXXX")"
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
|
||||
mkdir -p "$scratch/bin"
|
||||
artifact="$scratch/VniDrop.dmg"
|
||||
calls="$scratch/calls.txt"
|
||||
log_output="$scratch/notary/notary-log.json"
|
||||
printf 'dmg\n' > "$artifact"
|
||||
|
||||
cat > "$scratch/bin/xcrun" <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
printf '%s\n' "$*" >> "$FAKE_NOTARY_CALLS"
|
||||
if [[ $1 == notarytool && $2 == submit ]]; then
|
||||
case "${FAKE_NOTARY_MODE:-accepted}" in
|
||||
accepted)
|
||||
printf '%s\n' \
|
||||
'{"id":"11111111-1111-1111-1111-111111111111","status":"Accepted"}'
|
||||
;;
|
||||
invalid)
|
||||
printf '%s\n' \
|
||||
'{"id":"22222222-2222-2222-2222-222222222222","status":"Invalid"}'
|
||||
;;
|
||||
transport-error)
|
||||
printf '%s\n' 'notary service unavailable' >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
elif [[ $1 == notarytool && $2 == log ]]; then
|
||||
mkdir -p "$(dirname "$4")"
|
||||
printf '%s\n' \
|
||||
'{"status":"Invalid","issues":[{"message":"The signature is invalid."}]}' \
|
||||
> "$4"
|
||||
else
|
||||
printf 'unexpected xcrun invocation: %s\n' "$*" >&2
|
||||
exit 1
|
||||
fi
|
||||
SCRIPT
|
||||
chmod +x "$scratch/bin/xcrun"
|
||||
|
||||
PATH="$scratch/bin:$PATH" \
|
||||
FAKE_NOTARY_CALLS="$calls" \
|
||||
FAKE_NOTARY_MODE=accepted \
|
||||
"$notarize" "$artifact" test-profile "$log_output" >/dev/null
|
||||
[[ ! -e $log_output ]]
|
||||
[[ $(grep -c '^notarytool submit ' "$calls") -eq 1 ]]
|
||||
if grep -q '^notarytool log ' "$calls"; then
|
||||
printf 'Accepted submissions must not request a rejection log\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
: > "$calls"
|
||||
if PATH="$scratch/bin:$PATH" \
|
||||
FAKE_NOTARY_CALLS="$calls" \
|
||||
FAKE_NOTARY_MODE=invalid \
|
||||
"$notarize" "$artifact" test-profile "$log_output" >/dev/null 2>&1; then
|
||||
printf 'Invalid notarization must fail\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -F '"The signature is invalid."' "$log_output" >/dev/null
|
||||
grep -F \
|
||||
'notarytool log 22222222-2222-2222-2222-222222222222' \
|
||||
"$calls" >/dev/null
|
||||
|
||||
: > "$calls"
|
||||
rm -f "$log_output"
|
||||
if PATH="$scratch/bin:$PATH" \
|
||||
FAKE_NOTARY_CALLS="$calls" \
|
||||
FAKE_NOTARY_MODE=transport-error \
|
||||
"$notarize" "$artifact" test-profile "$log_output" >/dev/null 2>&1; then
|
||||
printf 'Notary transport errors must fail\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ ! -e $log_output ]]
|
||||
if grep -q '^notarytool log ' "$calls"; then
|
||||
printf 'A submission without an ID cannot request a rejection log\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Notarization helper tests passed.\n'
|
||||
78
apple/scripts/tests/test-sign-exported-app.sh
Executable file
78
apple/scripts/tests/test-sign-exported-app.sh
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
sign_exported_app="$script_dir/../sign-exported-app.sh"
|
||||
scratch="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-codesign-test.XXXXXX")"
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
|
||||
mkdir -p "$scratch/bin" "$scratch/VniDrop.app/Contents/MacOS"
|
||||
app="$scratch/VniDrop.app"
|
||||
entitlements="$scratch/VniDropDirect.entitlements"
|
||||
calls="$scratch/calls.txt"
|
||||
printf '<plist><dict/></plist>\n' > "$entitlements"
|
||||
printf 'binary\n' > "$app/Contents/MacOS/VniDrop"
|
||||
|
||||
cat > "$scratch/bin/codesign" <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
printf '%s\n' "$*" >> "$FAKE_CODESIGN_CALLS"
|
||||
case " $* " in
|
||||
*" --display "*)
|
||||
if [[ ${FAKE_CODESIGN_MODE:-runtime} == missing-runtime ]]; then
|
||||
printf '%s\n' \
|
||||
'CodeDirectory v=20500 size=123 flags=0x0(none) hashes=1+0 location=embedded' \
|
||||
>&2
|
||||
else
|
||||
printf '%s\n' \
|
||||
'CodeDirectory v=20500 size=123 flags=0x10000(runtime) hashes=1+0 location=embedded' \
|
||||
>&2
|
||||
fi
|
||||
;;
|
||||
*" --verify "*)
|
||||
if [[ ${FAKE_CODESIGN_MODE:-runtime} == verify-error ]]; then
|
||||
printf '%s\n' 'invalid signature' >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
SCRIPT
|
||||
chmod +x "$scratch/bin/codesign"
|
||||
|
||||
PATH="$scratch/bin:$PATH" \
|
||||
FAKE_CODESIGN_CALLS="$calls" \
|
||||
"$sign_exported_app" \
|
||||
"$app" \
|
||||
'Developer ID Application: Example (ABCDEFGHIJ)' \
|
||||
"$entitlements" >/dev/null
|
||||
grep -F -- \
|
||||
'--force --sign Developer ID Application: Example (ABCDEFGHIJ) --options runtime --timestamp --entitlements' \
|
||||
"$calls" >/dev/null
|
||||
grep -F -- '--verify --deep --strict --verbose=2' "$calls" >/dev/null
|
||||
grep -F -- '--display --verbose=4' "$calls" >/dev/null
|
||||
|
||||
if PATH="$scratch/bin:$PATH" \
|
||||
FAKE_CODESIGN_CALLS="$calls" \
|
||||
FAKE_CODESIGN_MODE=missing-runtime \
|
||||
"$sign_exported_app" \
|
||||
"$app" \
|
||||
'Developer ID Application: Example (ABCDEFGHIJ)' \
|
||||
"$entitlements" >/dev/null 2>&1; then
|
||||
printf 'A signature without the hardened runtime must fail\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if PATH="$scratch/bin:$PATH" \
|
||||
FAKE_CODESIGN_CALLS="$calls" \
|
||||
FAKE_CODESIGN_MODE=verify-error \
|
||||
"$sign_exported_app" \
|
||||
"$app" \
|
||||
'Developer ID Application: Example (ABCDEFGHIJ)' \
|
||||
"$entitlements" >/dev/null 2>&1; then
|
||||
printf 'Signature verification errors must fail\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Exported app signing tests passed.\n'
|
||||
@@ -53,18 +53,6 @@ verify_archive_entries() {
|
||||
done
|
||||
}
|
||||
|
||||
find_apksigner() {
|
||||
if command -v apksigner >/dev/null 2>&1; then
|
||||
command -v apksigner
|
||||
return
|
||||
fi
|
||||
local sdk_root=${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}
|
||||
[[ -n $sdk_root ]] || return 1
|
||||
find "$sdk_root/build-tools" -type f -name apksigner -perm -111 2>/dev/null |
|
||||
sort -r |
|
||||
head -1
|
||||
}
|
||||
|
||||
for name in \
|
||||
VNIDROP_ANDROID_KEYSTORE_PATH \
|
||||
VNIDROP_ANDROID_KEYSTORE_PASSWORD \
|
||||
@@ -119,26 +107,12 @@ grep -F 'jar verified.' <<< "$jarsigner_report" >/dev/null || {
|
||||
}
|
||||
verify_archive_entries "$source_apk" "${required_apk_libraries[@]}"
|
||||
verify_archive_entries "$source_aab" "${required_aab_libraries[@]}"
|
||||
apksigner_path="$(find_apksigner)" || {
|
||||
printf 'apksigner was not found in PATH or the Android SDK\n' >&2
|
||||
exit 1
|
||||
}
|
||||
signature_report="$("$apksigner_path" verify --verbose --print-certs "$source_apk")"
|
||||
actual_fingerprint="$(
|
||||
printf '%s\n' "$signature_report" |
|
||||
awk -F': ' '/Signer #1 certificate SHA-256 digest:/ {print $2; exit}'
|
||||
"$script_dir/verify-apk-signature.sh" \
|
||||
"$source_apk" \
|
||||
"$VNIDROP_ANDROID_UPLOAD_CERT_SHA256"
|
||||
)"
|
||||
[[ -n $actual_fingerprint ]] || {
|
||||
printf 'Could not read the APK signing certificate fingerprint\n' >&2
|
||||
exit 1
|
||||
}
|
||||
actual_fingerprint="$(normalize_fingerprint "$actual_fingerprint")"
|
||||
expected_fingerprint="$(normalize_fingerprint "$VNIDROP_ANDROID_UPLOAD_CERT_SHA256")"
|
||||
[[ $actual_fingerprint == "$expected_fingerprint" ]] || {
|
||||
printf 'APK signing certificate mismatch: expected %s, got %s\n' \
|
||||
"$expected_fingerprint" "$actual_fingerprint" >&2
|
||||
exit 1
|
||||
}
|
||||
aab_fingerprint="$(
|
||||
keytool -printcert -jarfile "$source_aab" |
|
||||
awk -F': ' '/SHA256:/ {print $2; exit}'
|
||||
|
||||
@@ -184,6 +184,19 @@ def generated_apks_url(package_name: str, version_code: int) -> str:
|
||||
return f"{API_ROOT}/applications/{package}/generatedApks/{version_code}"
|
||||
|
||||
|
||||
def generated_apk_download_url(
|
||||
package_name: str,
|
||||
version_code: int,
|
||||
download_id: str,
|
||||
) -> str:
|
||||
package = urllib.parse.quote(package_name, safe="")
|
||||
download = urllib.parse.quote(download_id, safe="")
|
||||
return (
|
||||
f"{API_ROOT}/applications/{package}/generatedApks/"
|
||||
f"{version_code}/downloads/{download}:download?alt=media"
|
||||
)
|
||||
|
||||
|
||||
def get_generated_apks(
|
||||
client: PlayClient,
|
||||
package_name: str,
|
||||
@@ -216,22 +229,23 @@ def download_universal_apk(
|
||||
selected = find_universal_apk(response, expected_fingerprint)
|
||||
if selected is not None:
|
||||
fingerprint, download_id = selected
|
||||
package = urllib.parse.quote(package_name, safe="")
|
||||
download = urllib.parse.quote(download_id, safe="")
|
||||
url = (
|
||||
f"{API_ROOT}/applications/{package}/generatedApks/"
|
||||
f"{version_code}/downloads/{download}:download"
|
||||
apk = client.request(
|
||||
"GET",
|
||||
generated_apk_download_url(
|
||||
package_name,
|
||||
version_code,
|
||||
download_id,
|
||||
),
|
||||
)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_bytes(client.request("GET", url))
|
||||
if output.stat().st_size == 0:
|
||||
raise RuntimeError("Google Play returned an empty universal APK")
|
||||
return fingerprint
|
||||
if apk:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_bytes(apk)
|
||||
return fingerprint
|
||||
if attempt < attempts:
|
||||
time.sleep(interval_seconds)
|
||||
raise RuntimeError(
|
||||
"Google Play did not provide a universal APK signed with the expected "
|
||||
f"certificate after {attempts} attempts"
|
||||
"Google Play did not provide a non-empty universal APK signed with the "
|
||||
f"expected certificate after {attempts} attempts"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import importlib.util
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
@@ -44,6 +45,69 @@ class PublishPlayTests(unittest.TestCase):
|
||||
("aabb", "correct"),
|
||||
)
|
||||
|
||||
def test_downloads_generated_apk_as_media(self):
|
||||
class FakePlayClient:
|
||||
def __init__(self):
|
||||
self.download_urls = []
|
||||
self.media_attempts = 0
|
||||
|
||||
def request_json(self, method, url):
|
||||
self.assert_request(method, url)
|
||||
return {
|
||||
"generatedApks": [
|
||||
{
|
||||
"certificateSha256Hash": "AA:BB",
|
||||
"generatedUniversalApk": {
|
||||
"downloadId": "download/id+=",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def request(self, method, url):
|
||||
self.assert_request(method, url)
|
||||
self.download_urls.append(url)
|
||||
if not url.endswith("?alt=media"):
|
||||
return b""
|
||||
self.media_attempts += 1
|
||||
return b"apk" if self.media_attempts == 2 else b""
|
||||
|
||||
@staticmethod
|
||||
def assert_request(method, url):
|
||||
if method != "GET" or not url.startswith(publish_play.API_ROOT):
|
||||
raise AssertionError(f"unexpected request: {method} {url}")
|
||||
|
||||
client = FakePlayClient()
|
||||
with tempfile.TemporaryDirectory() as scratch:
|
||||
output = Path(scratch) / "universal.apk"
|
||||
fingerprint = publish_play.download_universal_apk(
|
||||
client,
|
||||
"com.example app",
|
||||
2002,
|
||||
"aa:bb",
|
||||
output,
|
||||
attempts=2,
|
||||
interval_seconds=0,
|
||||
)
|
||||
self.assertEqual(fingerprint, "aabb")
|
||||
self.assertEqual(output.read_bytes(), b"apk")
|
||||
|
||||
self.assertEqual(
|
||||
client.download_urls,
|
||||
[
|
||||
(
|
||||
f"{publish_play.API_ROOT}/applications/com.example%20app/"
|
||||
"generatedApks/2002/downloads/"
|
||||
"download%2Fid%2B%3D:download?alt=media"
|
||||
),
|
||||
(
|
||||
f"{publish_play.API_ROOT}/applications/com.example%20app/"
|
||||
"generatedApks/2002/downloads/"
|
||||
"download%2Fid%2B%3D:download?alt=media"
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def test_track_update_preserves_existing_releases_and_adds_draft(self):
|
||||
track = {
|
||||
"track": "closed-beta",
|
||||
|
||||
57
packaging/android/tests/test_verify_apk_signature.sh
Executable file
57
packaging/android/tests/test_verify_apk_signature.sh
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
verifier="$script_dir/../verify-apk-signature.sh"
|
||||
scratch="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-apksigner-test.XXXXXX")"
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
|
||||
apk="$scratch/app.apk"
|
||||
fake_apksigner="$scratch/apksigner"
|
||||
printf 'apk\n' > "$apk"
|
||||
|
||||
cat > "$fake_apksigner" <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
case "${FAKE_APKSIGNER_MODE:-success}" in
|
||||
success)
|
||||
printf '%s\n' \
|
||||
'Verifies' \
|
||||
'Signer #1 certificate SHA-256 digest: AA:BB:CC:DD' >&2
|
||||
;;
|
||||
missing)
|
||||
printf '%s\n' 'Verifies' >&2
|
||||
;;
|
||||
failure)
|
||||
printf '%s\n' 'invalid APK signature' >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
SCRIPT
|
||||
chmod +x "$fake_apksigner"
|
||||
|
||||
actual="$(
|
||||
APKSIGNER="$fake_apksigner" \
|
||||
"$verifier" "$apk" "aa bb cc dd"
|
||||
)"
|
||||
[[ $actual == aabbccdd ]]
|
||||
|
||||
if APKSIGNER="$fake_apksigner" \
|
||||
"$verifier" "$apk" deadbeef >/dev/null 2>&1; then
|
||||
printf 'Expected a certificate mismatch to fail\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if FAKE_APKSIGNER_MODE=missing APKSIGNER="$fake_apksigner" \
|
||||
"$verifier" "$apk" aabbccdd >/dev/null 2>&1; then
|
||||
printf 'Expected missing certificate output to fail\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if FAKE_APKSIGNER_MODE=failure APKSIGNER="$fake_apksigner" \
|
||||
"$verifier" "$apk" aabbccdd >/dev/null 2>&1; then
|
||||
printf 'Expected signature verification failure to propagate\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'APK signature verifier tests passed.\n'
|
||||
86
packaging/android/verify-apk-signature.sh
Executable file
86
packaging/android/verify-apk-signature.sh
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 2 ]]; then
|
||||
printf 'Usage: %s <apk> <expected-certificate-sha256>\n' "$0" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
apk=$1
|
||||
expected_fingerprint=$2
|
||||
build_tools_version=${ANDROID_BUILD_TOOLS_VERSION:-36.0.0}
|
||||
|
||||
normalize_fingerprint() {
|
||||
printf '%s' "$1" |
|
||||
tr -d '[:space:]:' |
|
||||
tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
find_apksigner() {
|
||||
if [[ -n ${APKSIGNER:-} ]]; then
|
||||
[[ -x $APKSIGNER ]] || {
|
||||
printf 'Configured apksigner is not executable: %s\n' "$APKSIGNER" >&2
|
||||
return 1
|
||||
}
|
||||
printf '%s\n' "$APKSIGNER"
|
||||
return
|
||||
fi
|
||||
|
||||
local sdk_root=${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}
|
||||
if [[ -n $sdk_root ]]; then
|
||||
local pinned="$sdk_root/build-tools/$build_tools_version/apksigner"
|
||||
if [[ -x $pinned ]]; then
|
||||
printf '%s\n' "$pinned"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v apksigner >/dev/null 2>&1; then
|
||||
command -v apksigner
|
||||
return
|
||||
fi
|
||||
|
||||
printf 'apksigner %s was not found in the Android SDK or PATH\n' \
|
||||
"$build_tools_version" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
[[ -s $apk ]] || {
|
||||
printf 'APK is missing or empty: %s\n' "$apk" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
apksigner_path="$(find_apksigner)" || exit 1
|
||||
if ! signature_report="$(
|
||||
"$apksigner_path" verify --verbose --print-certs "$apk" 2>&1
|
||||
)"; then
|
||||
printf 'APK signature verification failed:\n%s\n' "$signature_report" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
actual_fingerprint="$(
|
||||
printf '%s\n' "$signature_report" |
|
||||
awk '
|
||||
tolower($0) ~ /^signer #1 certificate sha-256 digest:[[:space:]]*/ {
|
||||
line = $0
|
||||
sub(/^[^:]*:[[:space:]]*/, "", line)
|
||||
print line
|
||||
exit
|
||||
}
|
||||
'
|
||||
)"
|
||||
[[ -n $actual_fingerprint ]] || {
|
||||
printf 'Could not read the APK signing certificate fingerprint\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
actual_fingerprint="$(normalize_fingerprint "$actual_fingerprint")"
|
||||
expected_fingerprint="$(normalize_fingerprint "$expected_fingerprint")"
|
||||
[[ $actual_fingerprint == "$expected_fingerprint" ]] || {
|
||||
printf 'APK signing certificate mismatch: expected %s, got %s\n' \
|
||||
"$expected_fingerprint" "$actual_fingerprint" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
printf '%s\n' "$actual_fingerprint"
|
||||
56
packaging/release/test-release-config.sh
Executable file
56
packaging/release/test-release-config.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/../.." && pwd)"
|
||||
|
||||
dry_run="$(make -n -C "$repo_root" build-apple-dmg)"
|
||||
localization_line="$(
|
||||
printf '%s\n' "$dry_run" |
|
||||
awk '/bun run generate/ {print NR; exit}'
|
||||
)"
|
||||
build_line="$(
|
||||
printf '%s\n' "$dry_run" |
|
||||
awk '/apple\/scripts\/build-dmg\.sh/ {print NR; exit}'
|
||||
)"
|
||||
[[ -n $localization_line && -n $build_line && $localization_line -lt $build_line ]] || {
|
||||
printf 'build-apple-dmg must generate localization before building the DMG\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
grep -F 'run: make build-apple-dmg' \
|
||||
"$repo_root/.github/workflows/apple-release.yml" >/dev/null || {
|
||||
printf 'Apple release workflow must use the generated-input-aware Make target\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
store_reconfigure_line="$(
|
||||
awk '/msstore reconfigure/ {print NR; exit}' \
|
||||
"$repo_root/.github/workflows/release.yml"
|
||||
)"
|
||||
store_settings_line="$(
|
||||
awk '/msstore settings --enableTelemetry false/ {print NR; exit}' \
|
||||
"$repo_root/.github/workflows/release.yml"
|
||||
)"
|
||||
[[ -n $store_reconfigure_line &&
|
||||
-n $store_settings_line &&
|
||||
$store_reconfigure_line -lt $store_settings_line ]] || {
|
||||
printf 'Microsoft Store CLI credentials must be configured before changing settings\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
signing_line="$(
|
||||
awk '/sign-exported-app\.sh/ {print NR; exit}' \
|
||||
"$repo_root/apple/scripts/build-dmg.sh"
|
||||
)"
|
||||
dmg_line="$(
|
||||
awk '/echo "==> Building DMG"/ {print NR; exit}' \
|
||||
"$repo_root/apple/scripts/build-dmg.sh"
|
||||
)"
|
||||
[[ -n $signing_line && -n $dmg_line && $signing_line -lt $dmg_line ]] || {
|
||||
printf 'The exported app must enforce hardened-runtime signing before DMG creation\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
printf 'Release configuration tests passed.\n'
|
||||
@@ -1,3 +1,3 @@
|
||||
PRODUCT_VERSION=0.2.1
|
||||
PRODUCT_VERSION=0.2.4
|
||||
RELEASE_CHANNEL=beta
|
||||
WINDOWS_VERSION_EPOCH=1
|
||||
|
||||
Reference in New Issue
Block a user