Compare commits

..
Author SHA1 Message Date
such-gitea fb0c34cd70 fix(release): restore Mac signing keychain search 2026-10-17 08:27:14 -04:00
John Winter MurphyandGitHub e0670bede5 Merge pull request #3: retain candidate source and signing evidence
fix(release): bind candidate files and signing evidence to each run
2026-10-17 06:21:55 -04:00
such-gitea ea04f585aa fix(release): bind candidate hashes and signing evidence to each run 2026-10-17 06:20:40 -04:00
John Winter MurphyandGitHub 25a341fd10 Merge pull request #2: prepare Wowlet 5.1.5 security release
chore(release): prepare Wowlet 5.1.5
2026-10-17 06:04:13 -04:00
7 changed files with 269 additions and 28 deletions
+6 -1
View File
@@ -149,11 +149,16 @@ jobs:
ARCH=x86_64 ./appimagetool AppDir 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
# v3: Gitea implements the older artifact protocol and rejects
# @actions/artifact v2 clients (upload-artifact@v4+) as unsupported.
uses: actions/upload-artifact@v3
with:
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
+6 -1
View File
@@ -209,11 +209,16 @@ jobs:
ditto -c -k --keepParent Wowlet.app ../wowlet-macos-$(uname -m).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
uses: actions/upload-artifact@v3
with:
name: wowlet-macos
path: build/wowlet-macos-*.zip
path: |
build/wowlet-macos-*.zip
build/wowlet-macos-receipt.json
retention-days: 14
# Without this an empty upload passes silently, which is how a signed
# build can appear to publish while shipping nothing.
+54
View File
@@ -141,6 +141,9 @@ jobs:
(cp LICENSE stage/LICENSE.txt 2>/dev/null || cp COPYING stage/LICENSE.txt 2>/dev/null) || true
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
# v3, not v4: Gitea Actions implements the older artifact protocol and
# rejects @actions/artifact v2 clients with
@@ -156,12 +159,39 @@ jobs:
needs: build
runs-on: windows # gitea label windows:host -> desktop-iim2fs2
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
uses: actions/download-artifact@v3
with:
name: wowlet-x86_64-windows-unsigned
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
shell: powershell
run: |
@@ -265,6 +295,29 @@ jobs:
(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
uses: actions/upload-artifact@v3
with:
@@ -272,4 +325,5 @@ jobs:
path: |
wowlet-x86_64-windows.zip
wowlet-x86_64-windows-unsigned.zip
wowlet-windows-receipt.json
if-no-files-found: error
+68 -26
View File
@@ -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.
"""
import base64
from contextlib import contextmanager
import json
import os
from pathlib import Path
import secrets
import shlex
import subprocess
import sys
import tempfile
import uuid
FIELDS = (
@@ -33,7 +36,7 @@ def child_environment():
def security(operation, *values):
if operation not in {
"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")
command = " ".join(argument(value) for value in (operation, *values)) + "\n"
@@ -55,6 +58,8 @@ def security(operation, *values):
"native keychain operation returned an error")
raise RuntimeError(f"{operation}: {cause} (exit {result.returncode}); raw diagnostics withheld")
return result.stdout
def run(arguments, *, timeout=120):
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",
"errSecInternalComponent": "keychain denied access to the signing identity",
"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),
"native signing operation returned an error")
@@ -72,6 +85,27 @@ def run(arguments, *, timeout=120):
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):
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "wb") as stream:
@@ -114,32 +148,40 @@ def main():
security("set-key-partition-list", "-S", "apple-tool:,apple:", "-s",
"-k", password, str(keychain))
def sign(path):
run(["/usr/bin/codesign", "--force", "--options", "runtime", "--timestamp",
"--keychain", str(keychain), "--sign",
credentials["APPLE_SIGNING_IDENTITY"], str(path)])
with keychain_search(keychain):
def sign(path):
print("Signing component: " + (str(path.relative_to(app)) if path != app else "Wowlet.app"), flush=True)
run(["/usr/bin/codesign", "--force", "--options", "runtime", "--timestamp",
"--keychain", str(keychain), "--sign",
credentials["APPLE_SIGNING_IDENTITY"], str(path)])
for path in sorted(app.rglob("*")):
if path.is_file() and not any(part.endswith(".framework") for part in path.parts):
if "Mach-O" in run(["/usr/bin/file", "-b", str(path)]):
sign(path)
frameworks = app / "Contents/Frameworks"
for path in sorted(frameworks.glob("*.framework")):
sign(path)
sign(app)
run(["/usr/bin/codesign", "--verify", "--deep", "--strict", str(app)])
archive = workspace / "candidate.zip"
run(["/usr/bin/ditto", "-c", "-k", "--keepParent", str(app), str(archive)])
raw = run(["/usr/bin/xcrun", "notarytool", "submit", str(archive),
"--key", str(notary_key), "--key-id", credentials["APPLE_API_KEY"],
"--issuer", credentials["APPLE_API_ISSUER"], "--wait", "--timeout", "30m",
"--output-format", "json"], timeout=1900)
response = json.loads(raw)
if response.get("status") != "Accepted":
raise RuntimeError("Apple notarization did not return Accepted")
run(["/usr/bin/xcrun", "stapler", "staple", str(app)])
run(["/usr/bin/xcrun", "stapler", "validate", str(app)])
print("Developer ID signature verified; notarization accepted; staple validated")
for path in signable_files(app):
sign(path)
frameworks = app / "Contents/Frameworks"
for path in sorted(frameworks.glob("*.framework")):
sign(path)
sign(app)
run(["/usr/bin/codesign", "--verify", "--deep", "--strict", str(app)])
archive = workspace / "candidate.zip"
run(["/usr/bin/ditto", "-c", "-k", "--keepParent", str(app), str(archive)])
raw = run(["/usr/bin/xcrun", "notarytool", "submit", str(archive),
"--key", str(notary_key), "--key-id", credentials["APPLE_API_KEY"],
"--issuer", credentials["APPLE_API_ISSUER"], "--wait", "--timeout", "30m",
"--output-format", "json"], timeout=1900)
response = json.loads(raw)
if response.get("status") != "Accepted":
raise RuntimeError("Apple notarization did not return Accepted")
try:
uuid.UUID(response.get("id", ""))
except (ValueError, TypeError, AttributeError):
raise RuntimeError("Apple notarization submission identity is missing or malformed") from None
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:
if created:
security("delete-keychain", str(keychain))
+26
View File
@@ -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)
+50
View File
@@ -2,6 +2,7 @@ import importlib.util
from pathlib import Path
from types import SimpleNamespace
import unittest
import tempfile
from unittest.mock import patch
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):
signing.security("dump-keychain", "login.keychain")
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))
+59
View File
@@ -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()