8 Commits

12 changed files with 417 additions and 19 deletions

View File

@@ -134,6 +134,15 @@ jobs:
- name: Build, sign & notarize DMG - name: Build, sign & notarize DMG
run: make build-apple-dmg 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 - name: Generate appcast
env: env:
RELEASE_REPO: ${{ github.repository }} RELEASE_REPO: ${{ github.repository }}

View File

@@ -295,10 +295,6 @@ jobs:
SELLER_ID: ${{ secrets.SELLER_ID }} SELLER_ID: ${{ secrets.SELLER_ID }}
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }} MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
run: | run: |
msstore settings --enableTelemetry false
if ($LASTEXITCODE -ne 0) {
throw "Failed to disable Microsoft Store CLI telemetry"
}
msstore reconfigure ` msstore reconfigure `
--tenantId "$env:AZURE_AD_TENANT_ID" ` --tenantId "$env:AZURE_AD_TENANT_ID" `
--sellerId "$env:SELLER_ID" ` --sellerId "$env:SELLER_ID" `
@@ -307,6 +303,10 @@ jobs:
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "Microsoft Store authentication failed" 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" msstore apps get "$env:MICROSOFT_STORE_PRODUCT_ID"
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "The Microsoft Store application is not accessible" throw "The Microsoft Store application is not accessible"

View File

@@ -71,7 +71,9 @@ check-version: ## Validate the canonical version and its platform mappings.
cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS) cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS)
check-release: ## Validate coordinated release scripts and workflow YAML. check-release: ## Validate coordinated release scripts and workflow YAML.
cd $(ROOT) && bash -n 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) && 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/android/tests/test_verify_apk_signature.sh
cd $(ROOT) && packaging/release/test-assemble-release.sh cd $(ROOT) && packaging/release/test-assemble-release.sh
cd $(ROOT) && packaging/release/test-release-config.sh cd $(ROOT) && packaging/release/test-release-config.sh

View File

@@ -105,6 +105,12 @@ ACTUAL_BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' \
exit 1 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 ----------------------------------------------------------- # --- Build the DMG -----------------------------------------------------------
DMG="$DIST_DIR/$APP_NAME-$VERSION.dmg" DMG="$DIST_DIR/$APP_NAME-$VERSION.dmg"
rm -f "$DMG" rm -f "$DMG"
@@ -137,7 +143,8 @@ codesign --force --sign "$DEVELOPER_ID_APP" --timestamp "$DMG"
# --- Notarize + staple ------------------------------------------------------- # --- Notarize + staple -------------------------------------------------------
if [ -n "${NOTARY_PROFILE:-}" ]; then if [ -n "${NOTARY_PROFILE:-}" ]; then
echo "==> Notarizing (profile: $NOTARY_PROFILE)" 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" echo "==> Stapling"
xcrun stapler staple "$DMG" xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG" xcrun stapler validate "$DMG"

67
apple/scripts/notarize.sh Executable file
View 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

View 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
}

View 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'

View 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'

View File

@@ -184,6 +184,19 @@ def generated_apks_url(package_name: str, version_code: int) -> str:
return f"{API_ROOT}/applications/{package}/generatedApks/{version_code}" 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( def get_generated_apks(
client: PlayClient, client: PlayClient,
package_name: str, package_name: str,
@@ -216,22 +229,23 @@ def download_universal_apk(
selected = find_universal_apk(response, expected_fingerprint) selected = find_universal_apk(response, expected_fingerprint)
if selected is not None: if selected is not None:
fingerprint, download_id = selected fingerprint, download_id = selected
package = urllib.parse.quote(package_name, safe="") apk = client.request(
download = urllib.parse.quote(download_id, safe="") "GET",
url = ( generated_apk_download_url(
f"{API_ROOT}/applications/{package}/generatedApks/" package_name,
f"{version_code}/downloads/{download}:download" version_code,
download_id,
),
) )
output.parent.mkdir(parents=True, exist_ok=True) if apk:
output.write_bytes(client.request("GET", url)) output.parent.mkdir(parents=True, exist_ok=True)
if output.stat().st_size == 0: output.write_bytes(apk)
raise RuntimeError("Google Play returned an empty universal APK") return fingerprint
return fingerprint
if attempt < attempts: if attempt < attempts:
time.sleep(interval_seconds) time.sleep(interval_seconds)
raise RuntimeError( raise RuntimeError(
"Google Play did not provide a universal APK signed with the expected " "Google Play did not provide a non-empty universal APK signed with the "
f"certificate after {attempts} attempts" f"expected certificate after {attempts} attempts"
) )

View File

@@ -1,4 +1,5 @@
import importlib.util import importlib.util
import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -44,6 +45,69 @@ class PublishPlayTests(unittest.TestCase):
("aabb", "correct"), ("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): def test_track_update_preserves_existing_releases_and_adds_draft(self):
track = { track = {
"track": "closed-beta", "track": "closed-beta",

View File

@@ -25,4 +25,32 @@ grep -F 'run: make build-apple-dmg' \
exit 1 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' printf 'Release configuration tests passed.\n'

View File

@@ -1,3 +1,3 @@
PRODUCT_VERSION=0.2.1 PRODUCT_VERSION=0.2.4
RELEASE_CHANNEL=beta RELEASE_CHANNEL=beta
WINDOWS_VERSION_EPOCH=1 WINDOWS_VERSION_EPOCH=1