mirror of
https://github.com/wownero/wowlet.git
synced 2026-10-31 05:08:35 +01:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64529590a5 | ||
|
|
3c5aaed087 | ||
|
|
020b177bd1 | ||
|
|
fb0c34cd70 | ||
|
|
e0670bede5 | ||
|
|
ea04f585aa | ||
|
|
25a341fd10 |
@@ -149,11 +149,16 @@ jobs:
|
|||||||
ARCH=x86_64 ./appimagetool AppDir wowlet-x86_64.AppImage
|
ARCH=x86_64 ./appimagetool AppDir wowlet-x86_64.AppImage
|
||||||
ls -la wowlet-x86_64.AppImage
|
ls -la wowlet-x86_64.AppImage
|
||||||
|
|
||||||
|
- name: Record candidate source and hashes
|
||||||
|
run: python3 -B contrib/release/write_receipt.py --platform linux --file wowlet-x86_64.AppImage --output wowlet-linux-receipt.json
|
||||||
|
|
||||||
- name: upload wowlet AppImage
|
- name: upload wowlet AppImage
|
||||||
# v3: Gitea implements the older artifact protocol and rejects
|
# v3: Gitea implements the older artifact protocol and rejects
|
||||||
# @actions/artifact v2 clients (upload-artifact@v4+) as unsupported.
|
# @actions/artifact v2 clients (upload-artifact@v4+) as unsupported.
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: wowlet-x86_64-linux-AppImage
|
name: wowlet-x86_64-linux-AppImage
|
||||||
path: wowlet-x86_64.AppImage
|
path: |
|
||||||
|
wowlet-x86_64.AppImage
|
||||||
|
wowlet-linux-receipt.json
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|||||||
@@ -209,11 +209,16 @@ jobs:
|
|||||||
ditto -c -k --keepParent Wowlet.app ../wowlet-macos-$(uname -m).zip
|
ditto -c -k --keepParent Wowlet.app ../wowlet-macos-$(uname -m).zip
|
||||||
ls -la ../wowlet-macos-*.zip
|
ls -la ../wowlet-macos-*.zip
|
||||||
|
|
||||||
|
- name: Record signed candidate source and hashes
|
||||||
|
run: python3 -B contrib/release/write_receipt.py --platform macos --file build/wowlet-macos-$(uname -m).zip --output build/wowlet-macos-receipt.json
|
||||||
|
|
||||||
- name: upload artifact
|
- name: upload artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: wowlet-macos
|
name: wowlet-macos
|
||||||
path: build/wowlet-macos-*.zip
|
path: |
|
||||||
|
build/wowlet-macos-*.zip
|
||||||
|
build/wowlet-macos-receipt.json
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
# Without this an empty upload passes silently, which is how a signed
|
# Without this an empty upload passes silently, which is how a signed
|
||||||
# build can appear to publish while shipping nothing.
|
# build can appear to publish while shipping nothing.
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ jobs:
|
|||||||
(cp LICENSE stage/LICENSE.txt 2>/dev/null || cp COPYING stage/LICENSE.txt 2>/dev/null) || true
|
(cp LICENSE stage/LICENSE.txt 2>/dev/null || cp COPYING stage/LICENSE.txt 2>/dev/null) || true
|
||||||
ls -la stage stage/tor
|
ls -la stage stage/tor
|
||||||
|
|
||||||
|
- name: Record unsigned build source and hash
|
||||||
|
run: python3 -B contrib/release/write_receipt.py --platform windows-build --file stage/wowlet.exe --output stage/wowlet-build.json
|
||||||
|
|
||||||
- name: upload unsigned staging tree
|
- name: upload unsigned staging tree
|
||||||
# v3, not v4: Gitea Actions implements the older artifact protocol and
|
# v3, not v4: Gitea Actions implements the older artifact protocol and
|
||||||
# rejects @actions/artifact v2 clients with
|
# rejects @actions/artifact v2 clients with
|
||||||
@@ -156,12 +159,39 @@ jobs:
|
|||||||
needs: build
|
needs: build
|
||||||
runs-on: windows # gitea label windows:host -> desktop-iim2fs2
|
runs-on: windows # gitea label windows:host -> desktop-iim2fs2
|
||||||
steps:
|
steps:
|
||||||
|
- name: Prepare the exact signing run workspace
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
if ($env:EXPECTED_SHA -notmatch '^[0-9a-f]{40}$' -or $env:GITHUB_SHA -ne $env:EXPECTED_SHA) {
|
||||||
|
throw "Signing run differs from the reviewed source"
|
||||||
|
}
|
||||||
|
foreach ($path in @('stage', 'unsigned', 'wowlet-build.json', 'wowlet-windows-receipt.json', 'wowlet-x86_64-windows.zip', 'wowlet-x86_64-windows-unsigned.zip')) {
|
||||||
|
if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force }
|
||||||
|
}
|
||||||
|
|
||||||
- name: download unsigned staging tree
|
- name: download unsigned staging tree
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: wowlet-x86_64-windows-unsigned
|
name: wowlet-x86_64-windows-unsigned
|
||||||
path: stage
|
path: stage
|
||||||
|
|
||||||
|
- name: Verify exact signing source and unsigned input
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
if ($env:EXPECTED_SHA -notmatch '^[0-9a-f]{40}$' -or $env:GITHUB_SHA -ne $env:EXPECTED_SHA) {
|
||||||
|
throw "Signing run differs from the reviewed source"
|
||||||
|
}
|
||||||
|
$build = Get-Content 'stage/wowlet-build.json' -Raw | ConvertFrom-Json
|
||||||
|
if ($build.dispatch_commit -ne $env:EXPECTED_SHA -or $build.platform -ne 'windows-build' -or $build.artifacts.Count -ne 1) {
|
||||||
|
throw "Unsigned build receipt differs from the reviewed signing run"
|
||||||
|
}
|
||||||
|
if ($build.artifacts[0].name -ne 'wowlet.exe' -or $build.artifacts[0].sha256 -ne (Get-FileHash 'stage/wowlet.exe' -Algorithm SHA256).Hash.ToLowerInvariant()) {
|
||||||
|
throw "Unsigned executable differs from its build receipt"
|
||||||
|
}
|
||||||
|
Move-Item 'stage/wowlet-build.json' 'wowlet-build.json'
|
||||||
|
|
||||||
- name: keep the unsigned tree for the reproducible archive
|
- name: keep the unsigned tree for the reproducible archive
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: |
|
run: |
|
||||||
@@ -265,6 +295,29 @@ jobs:
|
|||||||
(Get-FileHash $archive -Algorithm SHA256).Hash
|
(Get-FileHash $archive -Algorithm SHA256).Hash
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- name: Record signed candidate source, hashes and certificate
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$build = Get-Content 'wowlet-build.json' -Raw | ConvertFrom-Json
|
||||||
|
$sig = Get-AuthenticodeSignature 'stage/wowlet.exe'
|
||||||
|
if ($sig.Status -ne 'Valid' -or -not $sig.TimeStamperCertificate -or $sig.SignerCertificate.Subject -notmatch 'Such Software LLC') {
|
||||||
|
throw "Final executable signature is not valid, timestamped and from the expected publisher"
|
||||||
|
}
|
||||||
|
$files = @('wowlet-x86_64-windows.zip', 'wowlet-x86_64-windows-unsigned.zip') | ForEach-Object {
|
||||||
|
@{name=$_;size=(Get-Item $_).Length;sha256=(Get-FileHash $_ -Algorithm SHA256).Hash.ToLowerInvariant()}
|
||||||
|
}
|
||||||
|
$receipt = @{
|
||||||
|
schema_version=1;app='wowlet';version=$build.version;platform='windows';
|
||||||
|
dispatch_commit=$env:GITHUB_SHA;wallet_library_commit=$build.wallet_library_commit;
|
||||||
|
environment_run_id=$env:GITHUB_RUN_ID;environment_run_attempt=$env:GITHUB_RUN_ATTEMPT;
|
||||||
|
artifacts=@($files);unsigned_executable=$build.artifacts[0];
|
||||||
|
signed_executable_sha256=(Get-FileHash 'stage/wowlet.exe' -Algorithm SHA256).Hash.ToLowerInvariant();
|
||||||
|
signing=@{status=$sig.Status.ToString();timestamped=$true;subject=$sig.SignerCertificate.Subject;
|
||||||
|
certificate_thumbprint=$sig.SignerCertificate.Thumbprint;certificate_not_after=$sig.SignerCertificate.NotAfter.ToUniversalTime().ToString('o')}
|
||||||
|
}
|
||||||
|
$receipt | ConvertTo-Json -Depth 6 | Set-Content -Encoding UTF8 'wowlet-windows-receipt.json'
|
||||||
|
|
||||||
- name: upload Windows zips
|
- name: upload Windows zips
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
@@ -272,4 +325,5 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
wowlet-x86_64-windows.zip
|
wowlet-x86_64-windows.zip
|
||||||
wowlet-x86_64-windows-unsigned.zip
|
wowlet-x86_64-windows-unsigned.zip
|
||||||
|
wowlet-windows-receipt.json
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
qt6-wayland-dev qt6-declarative-dev libzip-dev libsodium-dev libgcrypt20-dev libx11-xcb-dev
|
qt6-wayland-dev qt6-declarative-dev libzip-dev libsodium-dev libgcrypt20-dev libx11-xcb-dev
|
||||||
qml6-module-qtquick qml6-module-qtquick-layouts qml6-module-qtquick-window qml6-module-qtqml-workerscript qml6-module-qtqml-models
|
qml6-module-qtquick qml6-module-qtquick-layouts qml6-module-qtquick-window qml6-module-qtqml-workerscript qml6-module-qtqml-models
|
||||||
protobuf-compiler libprotobuf-dev libhidapi-dev libzxing-dev libusb-dev
|
protobuf-compiler libprotobuf-dev libhidapi-dev libzxing-dev libusb-dev
|
||||||
libusb-1.0-0-dev patchelf
|
libusb-1.0-0-dev libcom-err2 patchelf
|
||||||
- name: configure git
|
- name: configure git
|
||||||
run: git config --global --add safe.directory '*'
|
run: git config --global --add safe.directory '*'
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -77,10 +77,20 @@ jobs:
|
|||||||
export EXTRA_QT_PLUGINS="svg;multimedia;tls"
|
export EXTRA_QT_PLUGINS="svg;multimedia;tls"
|
||||||
export QML_SOURCES_PATHS="$PWD/src/assets/qml"
|
export QML_SOURCES_PATHS="$PWD/src/assets/qml"
|
||||||
export PATH="$PWD:$PATH"
|
export PATH="$PWD:$PATH"
|
||||||
|
# linuxdeploy's AppImage excludelist assumes libcom_err.so.2 is present on the host.
|
||||||
|
# Minimal Debian and openSUSE systems may omit it, but it is a transitive runtime dependency.
|
||||||
|
LIBCOM_ERR="$(ldconfig -p | awk '$1 == "libcom_err.so.2" { print $NF; exit }')"
|
||||||
|
if [[ -z "$LIBCOM_ERR" || ! -f "$LIBCOM_ERR" ]]; then
|
||||||
|
echo "ERROR: libcom_err.so.2 is unavailable in the build image; cannot make a portable AppImage." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
# Populate AppDir (libs + Qt plugins + QML modules) — no --output yet, so we can patch QML first.
|
# Populate AppDir (libs + Qt plugins + QML modules) — no --output yet, so we can patch QML first.
|
||||||
./linuxdeploy --appdir AppDir --plugin qt \
|
./linuxdeploy --appdir AppDir --plugin qt \
|
||||||
|
--library "$LIBCOM_ERR" \
|
||||||
-d AppDir/usr/share/applications/wowlet.desktop \
|
-d AppDir/usr/share/applications/wowlet.desktop \
|
||||||
-i AppDir/usr/share/icons/hicolor/256x256/apps/wowlet.png
|
-i AppDir/usr/share/icons/hicolor/256x256/apps/wowlet.png
|
||||||
|
# Guard this packaging regression: the AppImage must carry the excluded system library.
|
||||||
|
bash contrib/AppImage/check-libcom-err.sh AppDir
|
||||||
# ---------------------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------------------
|
||||||
# Make the QML bundle COMPLETE and deterministic (no whack-a-mole).
|
# Make the QML bundle COMPLETE and deterministic (no whack-a-mole).
|
||||||
#
|
#
|
||||||
|
|||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
appdir="${1:?usage: check-libcom-err.sh APPDIR}"
|
||||||
|
library="$(find "$appdir/usr/lib" -name libcom_err.so.2 -print -quit 2>/dev/null || true)"
|
||||||
|
if [[ -z "$library" || ! -e "$library" ]]; then
|
||||||
|
echo "ERROR: AppDir is missing the bundled libcom_err.so.2 runtime library." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -5,13 +5,16 @@ https://github.com/apple-oss-distributions/SecurityTool/blob/main/security.c
|
|||||||
Output stays captured because native diagnostics may repeat supplied input.
|
Output stays captured because native diagnostics may repeat supplied input.
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
|
from contextlib import contextmanager
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import secrets
|
import secrets
|
||||||
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
FIELDS = (
|
FIELDS = (
|
||||||
@@ -33,7 +36,7 @@ def child_environment():
|
|||||||
def security(operation, *values):
|
def security(operation, *values):
|
||||||
if operation not in {
|
if operation not in {
|
||||||
"create-keychain", "set-keychain-settings", "unlock-keychain", "import",
|
"create-keychain", "set-keychain-settings", "unlock-keychain", "import",
|
||||||
"set-key-partition-list", "delete-keychain",
|
"set-key-partition-list", "delete-keychain", "list-keychains",
|
||||||
}:
|
}:
|
||||||
raise ValueError("Keychain operation is outside candidate signing")
|
raise ValueError("Keychain operation is outside candidate signing")
|
||||||
command = " ".join(argument(value) for value in (operation, *values)) + "\n"
|
command = " ".join(argument(value) for value in (operation, *values)) + "\n"
|
||||||
@@ -55,6 +58,8 @@ def security(operation, *values):
|
|||||||
"native keychain operation returned an error")
|
"native keychain operation returned an error")
|
||||||
raise RuntimeError(f"{operation}: {cause} (exit {result.returncode}); raw diagnostics withheld")
|
raise RuntimeError(f"{operation}: {cause} (exit {result.returncode}); raw diagnostics withheld")
|
||||||
|
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
def run(arguments, *, timeout=120):
|
def run(arguments, *, timeout=120):
|
||||||
result = subprocess.run(arguments, text=True, capture_output=True,
|
result = subprocess.run(arguments, text=True, capture_output=True,
|
||||||
@@ -65,6 +70,14 @@ def run(arguments, *, timeout=120):
|
|||||||
"User interaction is not allowed": "signing key requires interactive access",
|
"User interaction is not allowed": "signing key requires interactive access",
|
||||||
"errSecInternalComponent": "keychain denied access to the signing identity",
|
"errSecInternalComponent": "keychain denied access to the signing identity",
|
||||||
"not signed at all": "candidate contains an unsigned component",
|
"not signed at all": "candidate contains an unsigned component",
|
||||||
|
"resource fork, Finder information": "candidate contains metadata forbidden by code signing",
|
||||||
|
"unable to build chain": "signing certificate chain could not be built",
|
||||||
|
"unsealed contents present": "embedded bundle has unsealed root contents",
|
||||||
|
"bundle format unrecognized": "component has an unsupported bundle format",
|
||||||
|
"timestamp service": "Apple timestamp service failed",
|
||||||
|
"Timestamping service": "Apple timestamp service failed",
|
||||||
|
"certificate has expired": "signing certificate has expired",
|
||||||
|
"CSSMERR_TP_CERT_EXPIRED": "signing certificate has expired",
|
||||||
}
|
}
|
||||||
cause = next((cause for marker, cause in causes.items() if marker in result.stderr),
|
cause = next((cause for marker, cause in causes.items() if marker in result.stderr),
|
||||||
"native signing operation returned an error")
|
"native signing operation returned an error")
|
||||||
@@ -72,6 +85,27 @@ def run(arguments, *, timeout=120):
|
|||||||
return result.stdout
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def keychain_search(keychain):
|
||||||
|
previous = [value for value in shlex.split(security("list-keychains", "-d", "user"))
|
||||||
|
if Path(value) != keychain]
|
||||||
|
if not previous or any(not Path(value).is_absolute() for value in previous):
|
||||||
|
raise RuntimeError("Existing user keychain search list is absent or malformed")
|
||||||
|
try:
|
||||||
|
security("list-keychains", "-d", "user", "-s", str(keychain), *previous)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
security("list-keychains", "-d", "user", "-s", *previous)
|
||||||
|
|
||||||
|
|
||||||
|
def signable_files(app):
|
||||||
|
for path in sorted(app.rglob("*")):
|
||||||
|
if path.is_symlink() or not path.is_file() or any(part.endswith(".framework") for part in path.parts):
|
||||||
|
continue
|
||||||
|
if "Mach-O" in run(["/usr/bin/file", "-b", str(path)]):
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
def private_file(path, content):
|
def private_file(path, content):
|
||||||
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
with os.fdopen(descriptor, "wb") as stream:
|
with os.fdopen(descriptor, "wb") as stream:
|
||||||
@@ -114,32 +148,40 @@ def main():
|
|||||||
security("set-key-partition-list", "-S", "apple-tool:,apple:", "-s",
|
security("set-key-partition-list", "-S", "apple-tool:,apple:", "-s",
|
||||||
"-k", password, str(keychain))
|
"-k", password, str(keychain))
|
||||||
|
|
||||||
def sign(path):
|
with keychain_search(keychain):
|
||||||
run(["/usr/bin/codesign", "--force", "--options", "runtime", "--timestamp",
|
def sign(path):
|
||||||
"--keychain", str(keychain), "--sign",
|
print("Signing component: " + (str(path.relative_to(app)) if path != app else "Wowlet.app"), flush=True)
|
||||||
credentials["APPLE_SIGNING_IDENTITY"], str(path)])
|
run(["/usr/bin/codesign", "--force", "--options", "runtime", "--timestamp",
|
||||||
|
"--keychain", str(keychain), "--sign",
|
||||||
|
credentials["APPLE_SIGNING_IDENTITY"], str(path)])
|
||||||
|
|
||||||
for path in sorted(app.rglob("*")):
|
for path in signable_files(app):
|
||||||
if path.is_file() and not any(part.endswith(".framework") for part in path.parts):
|
sign(path)
|
||||||
if "Mach-O" in run(["/usr/bin/file", "-b", str(path)]):
|
frameworks = app / "Contents/Frameworks"
|
||||||
sign(path)
|
for path in sorted(frameworks.glob("*.framework")):
|
||||||
frameworks = app / "Contents/Frameworks"
|
sign(path)
|
||||||
for path in sorted(frameworks.glob("*.framework")):
|
sign(app)
|
||||||
sign(path)
|
run(["/usr/bin/codesign", "--verify", "--deep", "--strict", str(app)])
|
||||||
sign(app)
|
archive = workspace / "candidate.zip"
|
||||||
run(["/usr/bin/codesign", "--verify", "--deep", "--strict", str(app)])
|
run(["/usr/bin/ditto", "-c", "-k", "--keepParent", str(app), str(archive)])
|
||||||
archive = workspace / "candidate.zip"
|
raw = run(["/usr/bin/xcrun", "notarytool", "submit", str(archive),
|
||||||
run(["/usr/bin/ditto", "-c", "-k", "--keepParent", str(app), str(archive)])
|
"--key", str(notary_key), "--key-id", credentials["APPLE_API_KEY"],
|
||||||
raw = run(["/usr/bin/xcrun", "notarytool", "submit", str(archive),
|
"--issuer", credentials["APPLE_API_ISSUER"], "--wait", "--timeout", "30m",
|
||||||
"--key", str(notary_key), "--key-id", credentials["APPLE_API_KEY"],
|
"--output-format", "json"], timeout=1900)
|
||||||
"--issuer", credentials["APPLE_API_ISSUER"], "--wait", "--timeout", "30m",
|
response = json.loads(raw)
|
||||||
"--output-format", "json"], timeout=1900)
|
if response.get("status") != "Accepted":
|
||||||
response = json.loads(raw)
|
raise RuntimeError("Apple notarization did not return Accepted")
|
||||||
if response.get("status") != "Accepted":
|
try:
|
||||||
raise RuntimeError("Apple notarization did not return Accepted")
|
uuid.UUID(response.get("id", ""))
|
||||||
run(["/usr/bin/xcrun", "stapler", "staple", str(app)])
|
except (ValueError, TypeError, AttributeError):
|
||||||
run(["/usr/bin/xcrun", "stapler", "validate", str(app)])
|
raise RuntimeError("Apple notarization submission identity is missing or malformed") from None
|
||||||
print("Developer ID signature verified; notarization accepted; staple validated")
|
run(["/usr/bin/xcrun", "stapler", "staple", str(app)])
|
||||||
|
run(["/usr/bin/xcrun", "stapler", "validate", str(app)])
|
||||||
|
private_file(app.parent / "macos-signing.json", json.dumps({
|
||||||
|
"developer_id_verified": True, "notarization_status": "Accepted",
|
||||||
|
"notary_submission_id": response.get("id"), "staple_validated": True,
|
||||||
|
}, sort_keys=True).encode())
|
||||||
|
print("Developer ID signature verified; notarization accepted; staple validated")
|
||||||
finally:
|
finally:
|
||||||
if created:
|
if created:
|
||||||
security("delete-keychain", str(keychain))
|
security("delete-keychain", str(keychain))
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
CHECKER = ROOT / "contrib/AppImage/check-libcom-err.sh"
|
||||||
|
|
||||||
|
|
||||||
|
class AppImageDependencyBundleTest(unittest.TestCase):
|
||||||
|
def test_accepts_appdir_with_libcom_err(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
appdir = Path(temporary) / "AppDir"
|
||||||
|
library = appdir / "usr/lib/libcom_err.so.2"
|
||||||
|
library.parent.mkdir(parents=True)
|
||||||
|
library.write_bytes(b"fixture")
|
||||||
|
|
||||||
|
result = subprocess.run(["bash", str(CHECKER), str(appdir)], check=False)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0)
|
||||||
|
|
||||||
|
def test_refuses_appdir_without_libcom_err(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
appdir = Path(temporary) / "AppDir"
|
||||||
|
(appdir / "usr/lib").mkdir(parents=True)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["bash", str(CHECKER), str(appdir)],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertTrue(result.stderr.strip())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||||
|
SPEC = importlib.util.spec_from_file_location("write_receipt", Path(__file__).parents[1] / "write_receipt.py")
|
||||||
|
receipt = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(receipt)
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptTests(unittest.TestCase):
|
||||||
|
def test_hash_changes_with_artifact_bytes_and_symlinks_refuse(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "candidate.zip"
|
||||||
|
path.write_bytes(b"candidate")
|
||||||
|
first = receipt.file_record(path)
|
||||||
|
path.write_bytes(b"different candidate")
|
||||||
|
second = receipt.file_record(path)
|
||||||
|
self.assertNotEqual(first["sha256"], second["sha256"])
|
||||||
|
self.assertEqual(second["size"], path.stat().st_size)
|
||||||
|
link = Path(tmp) / "link.zip"
|
||||||
|
link.symlink_to(path)
|
||||||
|
with self.assertRaisesRegex(ValueError, "regular file"):
|
||||||
|
receipt.file_record(link)
|
||||||
@@ -2,6 +2,7 @@ import importlib.util
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
import unittest
|
import unittest
|
||||||
|
import tempfile
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
spec = importlib.util.spec_from_file_location("sign_macos", Path(__file__).parents[1] / "sign_macos.py")
|
spec = importlib.util.spec_from_file_location("sign_macos", Path(__file__).parents[1] / "sign_macos.py")
|
||||||
@@ -43,3 +44,52 @@ class MacSigningTests(unittest.TestCase):
|
|||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
signing.security("dump-keychain", "login.keychain")
|
signing.security("dump-keychain", "login.keychain")
|
||||||
execute.assert_not_called()
|
execute.assert_not_called()
|
||||||
|
|
||||||
|
def test_user_keychain_search_is_restored_after_signing_failure(self):
|
||||||
|
previous = ['/Users/runner/Library/Keychains/login.keychain-db', '/Library/Keychains/System.keychain']
|
||||||
|
calls = []
|
||||||
|
def security(operation, *values):
|
||||||
|
calls.append((operation, values))
|
||||||
|
return ' '.join('"' + value + '"' for value in previous)
|
||||||
|
with patch.object(signing, "security", security):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "synthetic signing failure"):
|
||||||
|
with signing.keychain_search(Path('/private/candidate.keychain-db')):
|
||||||
|
self.assertEqual(calls[-1][1][-len(previous):], tuple(previous))
|
||||||
|
self.assertIn('/private/candidate.keychain-db', calls[-1][1])
|
||||||
|
raise RuntimeError('synthetic signing failure')
|
||||||
|
self.assertEqual(calls[-1], ('list-keychains', ('-d', 'user', '-s', *previous)))
|
||||||
|
|
||||||
|
def test_failed_search_list_write_still_restores_original_list(self):
|
||||||
|
calls = []
|
||||||
|
previous = '/Users/runner/Library/Keychains/login.keychain-db'
|
||||||
|
def security(operation, *values):
|
||||||
|
calls.append((operation, values))
|
||||||
|
if '/private/candidate.keychain-db' in values:
|
||||||
|
raise RuntimeError('synthetic search write failure')
|
||||||
|
return '"' + previous + '"'
|
||||||
|
with patch.object(signing, "security", security):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
with signing.keychain_search(Path('/private/candidate.keychain-db')):
|
||||||
|
self.fail('failed search-list setup entered signing')
|
||||||
|
self.assertEqual(calls[-1][1], ('-d', 'user', '-s', previous))
|
||||||
|
|
||||||
|
def test_signing_walk_skips_symlink_aliases_and_framework_internals(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
app = Path(temporary)
|
||||||
|
executable = app / 'actual.dylib'
|
||||||
|
executable.write_bytes(b'mach-o fixture')
|
||||||
|
(app / 'alias.dylib').symlink_to(executable.name)
|
||||||
|
framework = app / 'QtCore.framework'
|
||||||
|
framework.mkdir()
|
||||||
|
(framework / 'QtCore').write_bytes(b'framework fixture')
|
||||||
|
with patch.object(signing, 'run', return_value='Mach-O arm64'):
|
||||||
|
self.assertEqual(list(signing.signable_files(app)), [executable])
|
||||||
|
|
||||||
|
def test_native_failure_cause_does_not_echo_untrusted_diagnostics(self):
|
||||||
|
sentinel = 'must-not-echo-native-input'
|
||||||
|
result = SimpleNamespace(returncode=1, stdout='', stderr='unable to build chain ' + sentinel)
|
||||||
|
with patch.object(signing.subprocess, 'run', return_value=result):
|
||||||
|
with self.assertRaises(RuntimeError) as caught:
|
||||||
|
signing.run(['/usr/bin/codesign', 'public-component'])
|
||||||
|
self.assertIn('certificate chain', str(caught.exception))
|
||||||
|
self.assertNotIn(sentinel, str(caught.exception))
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Bind candidate archive hashes to the checked source and provider run."""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from check_dispatch import check
|
||||||
|
|
||||||
|
|
||||||
|
def file_record(path):
|
||||||
|
if not path.is_file() or path.is_symlink() or path.stat().st_size == 0:
|
||||||
|
raise ValueError("Candidate artifact must be a nonempty regular file")
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
while chunk := stream.read(1024 * 1024):
|
||||||
|
digest.update(chunk)
|
||||||
|
return {"name": path.name, "size": path.stat().st_size, "sha256": digest.hexdigest()}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--platform", required=True, choices=("linux", "macos", "windows-build"))
|
||||||
|
parser.add_argument("--file", type=Path, required=True, action="append")
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
head = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
|
||||||
|
check(os.environ.get("EXPECTED_SHA"), os.environ.get("GITHUB_SHA"), head)
|
||||||
|
run_id = os.environ.get("GITHUB_RUN_ID", "")
|
||||||
|
if not re.fullmatch(r"[1-9][0-9]*", run_id):
|
||||||
|
raise ValueError("Provider run identity is absent")
|
||||||
|
version = re.findall(r'^\s*VERSION "([0-9]+\.[0-9]+\.[0-9]+)"\s*$', Path("CMakeLists.txt").read_text(), re.M)
|
||||||
|
if len(version) != 1:
|
||||||
|
raise ValueError("Committed Wowlet version is ambiguous")
|
||||||
|
module = subprocess.check_output(["git", "ls-tree", "HEAD", "monero"], text=True).split()
|
||||||
|
if len(module) != 4 or module[:2] != ["160000", "commit"] or module[3] != "monero":
|
||||||
|
raise ValueError("Committed wallet-library pin is absent")
|
||||||
|
files = [file_record(path) for path in args.file]
|
||||||
|
if len({row["name"] for row in files}) != len(files):
|
||||||
|
raise ValueError("Candidate artifact filenames repeat")
|
||||||
|
receipt = {"schema_version": 1, "app": "wowlet", "version": version[0], "platform": args.platform,
|
||||||
|
"dispatch_commit": head, "wallet_library_commit": module[2],
|
||||||
|
"environment_run_id": run_id, "environment_run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT"),
|
||||||
|
"artifacts": files}
|
||||||
|
if args.platform == "macos":
|
||||||
|
signing = json.loads(Path("build/dist/macos-signing.json").read_text())
|
||||||
|
if signing.get("developer_id_verified") is not True or signing.get("notarization_status") != "Accepted" or signing.get("staple_validated") is not True:
|
||||||
|
raise ValueError("macOS signing evidence is incomplete")
|
||||||
|
receipt["signing"] = signing
|
||||||
|
with args.output.open("x") as stream:
|
||||||
|
json.dump(receipt, stream, indent=2)
|
||||||
|
stream.write("\n")
|
||||||
|
print("Candidate source and artifact hashes recorded")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user