diff --git a/.gitea/workflows/build-linux.yml b/.gitea/workflows/build-linux.yml index ccc57767..2258a194 100644 --- a/.gitea/workflows/build-linux.yml +++ b/.gitea/workflows/build-linux.yml @@ -53,21 +53,11 @@ jobs: - name: Verify exact reviewed source run: python3 -B contrib/release/check_dispatch.py - - name: fetch wownerod to embed + - name: fetch verified wownerod to embed run: | - # ubuntu-latest and windows-latest both resolve to the same host in - # host mode, so a fixed /tmp path lets a concurrently dispatched job's - # `rm -rf` delete this job's binary between fetch and configure. Scope - # every extract to the run. - EMBED="/tmp/wowlet-${GITHUB_RUN_ID:-local}" - rm -rf "$EMBED/wownerod-embed" && mkdir -p "$EMBED/wownerod-embed" - curl -fL -o "$EMBED/wownero.tar.bz2" \ - https://github.com/wownero/wownero-codeberg-mirror/releases/download/v0.11.4.0/wownero-x86_64-linux-gnu-v0.11.4.0.tar.bz2 - rm -rf "$EMBED/wow-x" && mkdir -p "$EMBED/wow-x" - tar xjf "$EMBED/wownero.tar.bz2" -C "$EMBED/wow-x" - find "$EMBED/wow-x" -name wownerod -type f -exec cp {} "$EMBED/wownerod-embed/" \; - chmod +x "$EMBED/wownerod-embed/wownerod" - ls -la "$EMBED/wownerod-embed" + python3 -B contrib/release/fetch_wownerod.py \ + --target x86_64-linux-gnu \ + --destination "/tmp/wowlet-${GITHUB_RUN_ID}/wownerod-embed" - name: fetch tor to embed run: | diff --git a/.gitea/workflows/build-macos.yml b/.gitea/workflows/build-macos.yml index 741e12a7..b23fc806 100644 --- a/.gitea/workflows/build-macos.yml +++ b/.gitea/workflows/build-macos.yml @@ -12,7 +12,7 @@ name: macos build (gitea self-hosted) # APPLE_CERTIFICATE (b64 Developer ID Application .p12), # APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY, APPLE_TEAM_ID, # APPLE_API_ISSUER, APPLE_API_KEY, APPLE_API_KEY_P8_BASE64 (notarytool). -# If APPLE_CERTIFICATE is unset the build ships unsigned instead of failing. +# Missing signing or notarization inputs refuse before compilation. on: workflow_dispatch: @@ -59,22 +59,17 @@ jobs: } done - - name: fetch wownerod to embed + - name: fetch verified wownerod to embed run: | set -e - ARCH=$(uname -m) - case "$ARCH" in - arm64) WOWTAR=wownero-aarch64-apple-darwin11-v0.11.4.0.tar.bz2 ;; - *) WOWTAR=wownero-x86_64-apple-darwin11-v0.11.4.0.tar.bz2 ;; + case "$(uname -m)" in + arm64) target=aarch64-apple-darwin11 ;; + x86_64) target=x86_64-apple-darwin11 ;; + *) echo "Unsupported macOS build architecture" >&2; exit 1 ;; esac - mkdir -p /tmp/wownerod-embed /tmp/wow-extract - curl -L -o /tmp/wownero.tar.bz2 \ - "https://github.com/wownero/wownero-codeberg-mirror/releases/download/v0.11.4.0/$WOWTAR" - # /tmp is a symlink on macOS — find(1) won't descend it as a bare argument - tar xjf /tmp/wownero.tar.bz2 -C /tmp/wow-extract - find /tmp/wow-extract -name wownerod -type f -exec cp {} /tmp/wownerod-embed/ \; - chmod +x /tmp/wownerod-embed/wownerod - ls -la /tmp/wownerod-embed + EMBED="/tmp/wowlet-${GITHUB_RUN_ID}/wownerod-embed" + python3 -B contrib/release/fetch_wownerod.py --target "$target" --destination "$EMBED" + echo "WOWNEROD_DIR=$EMBED" >> "$GITHUB_ENV" - name: fetch tor to embed run: | @@ -108,7 +103,7 @@ jobs: export CPATH="$(brew --prefix)/include" export LIBRARY_PATH="$(brew --prefix)/lib" mkdir -p build && cd build - cmake -DWITH_SCANNER=OFF -DUSE_DEVICE_TREZOR=OFF -DBoost_USE_STATIC_LIBS=ON -DWOWNEROD_DIR=/tmp/wownerod-embed -DTOR_DIR=/tmp/tor-embed -DTOR_VERSION="$TOR_VERSION" -DCMAKE_PREFIX_PATH="$(brew --prefix qt)" .. + cmake -DWITH_SCANNER=OFF -DUSE_DEVICE_TREZOR=OFF -DBoost_USE_STATIC_LIBS=ON -DWOWNEROD_DIR="$WOWNEROD_DIR" -DTOR_DIR=/tmp/tor-embed -DTOR_VERSION="$TOR_VERSION" -DCMAKE_PREFIX_PATH="$(brew --prefix qt)" .. cmake --build . -j "$(sysctl -n hw.ncpu)" - name: package app bundle diff --git a/.gitea/workflows/build-windows.yml b/.gitea/workflows/build-windows.yml index 4639ec2f..6f30e9e1 100644 --- a/.gitea/workflows/build-windows.yml +++ b/.gitea/workflows/build-windows.yml @@ -107,19 +107,11 @@ jobs: path: contrib/depends/built key: depends-built-mingw32-${{ hashFiles('contrib/depends/packages/*', 'contrib/depends/hosts/*', 'contrib/depends/toolchain.cmake.in') }} - - name: fetch wownerod.exe to embed + - name: fetch verified wownerod to embed run: | - # This job and the Linux one land on the same host in host mode, and a - # fixed /tmp path let each wipe the other's binary mid-build. Scope the - # extract to the run. - EMBED="/tmp/wowlet-${GITHUB_RUN_ID:-local}" - rm -rf "$EMBED/wownerod-embed" && mkdir -p "$EMBED/wownerod-embed" - curl -fL -o "$EMBED/wownero-win.zip" \ - https://github.com/wownero/wownero-codeberg-mirror/releases/download/v0.11.4.0/wownero-x86_64-w64-mingw32-v0.11.4.0.zip - # -j flattens the versioned subdir so wownerod.exe is a DIRECT child - # (WownerodQrcGenerator globs ${WOWNEROD_DIR}/* non-recursively). - unzip -o -j "$EMBED/wownero-win.zip" 'wownero-x86_64-w64-mingw32-v0.11.4.0/wownerod.exe' -d "$EMBED/wownerod-embed" - ls -la "$EMBED/wownerod-embed" + python3 -B contrib/release/fetch_wownerod.py \ + --target x86_64-w64-mingw32 \ + --destination "/tmp/wowlet-${GITHUB_RUN_ID}/wownerod-embed" - name: build wowlet.exe run: | diff --git a/RELEASE.md b/RELEASE.md index 6fc32981..1bf7b9da 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,46 +1,41 @@ -# Release process +# Wowlet release process -- Before a minor release: - - Update `src/assets/restore_heights_monero_{mainnet,stagenet}.txt` - - To obtain values, run `contrib/generate-restore-heights/heights.py` - - Update default node lists in `src/assets/nodes.json` - - Bump `openssl`, `qt`, `tor_*` packages in `contrib/depends/packages` - - Update or patch any statically linked dependencies that have known vulnerabilities - - Run `feather-utils/depends/vulns.py` to check - - Rebase on top of latest Monero version -- Update `src/assets/ack.txt` -- Update documentation (`feather-wallet/feather-docs`) and `external/feather-docs` submodule -- Update the version number in `CMakeLists.txt` - - A hardfork-ready release must bump major version -- Create an annotated tag (`git tag x.x.x -a`) - - Tag must match version in `CMakeLists.txt` - - Only commits that update the version number in `CMakeLists.txt` may be tagged -- Push the master branch and tags - - `git push --tags origin master` -- Run `guix` builds in a clean repo: - - ```bash - git clone https://github.com/feather-wallet/feather.git - cd feather - git checkout - git submodule update --init --recursive - ./contrib/guix/guix-build - ``` - - Alternatively, run `feather-utils/guix/run-build.sh` -- Attest the builds (`contrib/guix/guix-attest`) and PR to `feather/feather-sigs`. - - Wait until at least one other person has independently verified reproducibility - - If builds are not reproducible: fix any reproducibility defects and bump patch version. Do not sign or release non-reproducible builds. -- Sign release artifacts and hashlists. - - Transfer files in `guix-build-x.x.x/output` to release signing machine - - Run `feather-utils/release/make-release.sh` -- Update the site (`feather-wallet/feather-site`) - - Run the `feather-utils/site/bump-version.py` script to create a template commit. - - Edit the changelog in `content/changelog` - - If a release includes security fixes add "(includes security fixes)" to the title. - - Update the version number, file sizes and paths in `data/release.json` - - Upload releases, signatures and signed hashlists. - - Follow the directory structure defined in `MainWindow::onShowUpdateCheck`. - - Make `depends` source files [available](https://featherwallet.org/files/sources/): - - `make -C contrib/depends download` -- Announce release on irc/Matrix -- Update websocket servers to notify clients of new release - - Wait up to 7 days to allow for bug reports before major rollout +GitHub `wownero/wowlet` owns source and public releases. Private `Builds/wowlet` +is the build ingress on our hardware. Fleet owns dispatch and publication plans. +A source merge, a build, a code signature and a published release are separate outcomes. + +1. Prepare and review a GitHub pull request with the version, wallet-library pin, + support documentation and release inputs. Publish a new library commit to its + canonical repository before pinning it. Keep source and dependency revisions exact. +2. `make check` runs the cheap source checks. Run the private Linux, Windows and + macOS workflows through reviewed Fleet admission with `expected_sha` equal to + the protected Builds commit. Preserve canonical source and Builds commit identities + separately. Do not run release builds on GitHub. +3. The build helpers verify embedded Wownero archives against `contrib/release/wownerod.json`. + That file records the reviewed checksum provenance. Do not describe an unsigned + checksum list as a PGP verification. +4. Collect artifacts by their run and artifact identities. Require a Linux AppImage, + macOS archive, signed Windows archive and unsigned Windows archive. Check archive + paths, bundled runtimes and packaging requirements. Never select an artifact by size. +5. Require Developer ID signing, accepted notarization and stapling for macOS. + Require valid, timestamped Such Software LLC Authenticode signing for Windows. + The EV token stays on its signing host; CI never receives its password or key. +6. Record any manual wallet checks actually performed. Manual testing and independent + reproducibility are separate evidence; successful builds do not prove either. + The unsigned Windows archive is provided for reproduction, without claiming an + independent reproduction occurred. +7. Create `hashes.txt` over the four final archives. Sign it on the owner's signing + machine with the existing release key `9F9930300530DF724761B7055A881347310EB79F`. + Verify the detached signature and all hashes before publication. Never transfer + the private key or put a passphrase in a command, environment or log. +8. Create an annotated `vX.Y.Z` tag from the exact reviewed canonical source, matching + `CMakeLists.txt`. Use Fleet's reviewed publication plan to upload the four archives, + `hashes.txt` and `hashes.txt.asc` to GitHub Releases. Refuse replacement of existing + tags or assets; recover partial publication from retained intent. +9. Download the public assets again and verify their hashes and PGP signature. Record + the tag, source commit, Builds commit, run identities, signing evidence and public + URL in the private journal. Release notes should say when security fixes are included. + +Additional architectures and Guix builds remain separate lanes. Do not claim a +platform is released merely because its workflow exists. Announcements are an +owner action, separate from publishing the release files. diff --git a/contrib/release/fetch_wownerod.py b/contrib/release/fetch_wownerod.py new file mode 100644 index 00000000..729585e5 --- /dev/null +++ b/contrib/release/fetch_wownerod.py @@ -0,0 +1,73 @@ +"""Fetch the pinned daemon archive and copy only its verified daemon binary.""" +import argparse +import hashlib +import json +from pathlib import Path, PurePosixPath +import shutil +import tarfile +import tempfile +import urllib.request +import zipfile + +MAX_ARCHIVE = 300 * 1024 * 1024 +MAX_BINARY = 128 * 1024 * 1024 +TARGETS = {"x86_64-linux-gnu", "x86_64-w64-mingw32", "aarch64-apple-darwin11", "x86_64-apple-darwin11"} + + +def copy_daemon(archive, destination, windows): + name = "wownerod.exe" if windows else "wownerod" + if windows: + container = zipfile.ZipFile(archive) + members = [m for m in container.infolist() if PurePosixPath(m.filename).name == name] + valid = lambda m: not m.is_dir() and (m.external_attr >> 16 & 0o170000) in (0, 0o100000) and 0 < m.file_size <= MAX_BINARY + reader = container.open + else: + container = tarfile.open(archive, "r:bz2") + members = [m for m in container.getmembers() if PurePosixPath(m.name).name == name] + valid = lambda m: m.isfile() and 0 < m.size <= MAX_BINARY + reader = container.extractfile + with container: + if len(members) != 1 or not valid(members[0]): + raise ValueError("Pinned archive must contain exactly one regular daemon binary") + # Do not extract archive paths, symlinks or unrelated files. + with reader(members[0]) as source, (destination / name).open("xb") as output: + shutil.copyfileobj(source, output) + (destination / name).chmod(0o755) + + +def fetch(target, destination): + manifest = json.loads(Path(__file__).with_name("wownerod.json").read_text()) + suffix = ".zip" if target.endswith("mingw32") else ".tar.bz2" + filename = f"wownero-{target}-{manifest['version']}{suffix}" + expected = manifest["archives"][filename] + destination.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="wowlet-daemon-") as staging: + archive = Path(staging) / filename + digest = hashlib.sha256() + size = 0 + url = f"https://github.com/wownero/wownero/releases/download/{manifest['version']}/{filename}" + with urllib.request.urlopen(url, timeout=120) as response, archive.open("xb") as output: + if not response.geturl().startswith("https://"): + raise ValueError("Daemon download redirected outside HTTPS") + while chunk := response.read(1024 * 1024): + size += len(chunk) + if size > MAX_ARCHIVE: + raise ValueError("Daemon archive exceeds size bound") + digest.update(chunk) + output.write(chunk) + if digest.hexdigest() != expected: + raise ValueError("Daemon archive SHA-256 differs from the reviewed release pin") + copy_daemon(archive, destination, target.endswith("mingw32")) + print(f"Verified {filename}: {expected}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target", required=True, choices=sorted(TARGETS)) + parser.add_argument("--destination", required=True, type=Path) + args = parser.parse_args() + fetch(args.target, args.destination) + + +if __name__ == "__main__": + main() diff --git a/contrib/release/tests/test_fetch_wownerod.py b/contrib/release/tests/test_fetch_wownerod.py new file mode 100644 index 00000000..62a62251 --- /dev/null +++ b/contrib/release/tests/test_fetch_wownerod.py @@ -0,0 +1,49 @@ +import importlib.util +import io +from pathlib import Path +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +SPEC = importlib.util.spec_from_file_location("fetch_wownerod", Path(__file__).parents[1] / "fetch_wownerod.py") +fetcher = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(fetcher) + + +class DaemonTests(unittest.TestCase): + def test_checksum_failure_never_extracts_a_daemon(self): + response = io.BytesIO(b"untrusted archive bytes") + response.geturl = lambda: "https://release-assets.githubusercontent.com/archive" + with tempfile.TemporaryDirectory() as tmp, patch.object(fetcher.urllib.request, "urlopen", return_value=response), patch.object(fetcher, "copy_daemon") as extract: + with self.assertRaisesRegex(ValueError, "SHA-256"): + fetcher.fetch("x86_64-linux-gnu", Path(tmp) / "daemon") + extract.assert_not_called() + self.assertFalse((Path(tmp) / "daemon/wownerod").exists()) + + def test_archive_paths_cannot_write_outside_destination(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + archive = root / "daemon.tar.bz2" + with tarfile.open(archive, "w:bz2") as output: + info = tarfile.TarInfo("../../wownerod") + info.size = 3 + output.addfile(info, io.BytesIO(b"exe")) + destination = root / "stage" + destination.mkdir() + fetcher.copy_daemon(archive, destination, False) + self.assertEqual((destination / "wownerod").read_bytes(), b"exe") + self.assertFalse((root / "wownerod").exists()) + + def test_symlink_daemon_is_refused(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + archive = root / "daemon.tar.bz2" + with tarfile.open(archive, "w:bz2") as output: + info = tarfile.TarInfo("wownerod") + info.type = tarfile.SYMTYPE + info.linkname = "/etc/passwd" + output.addfile(info) + with self.assertRaisesRegex(ValueError, "regular daemon"): + fetcher.copy_daemon(archive, root, False) + self.assertFalse((root / "wownerod").exists()) diff --git a/contrib/release/wownerod.json b/contrib/release/wownerod.json new file mode 100644 index 00000000..c20eff55 --- /dev/null +++ b/contrib/release/wownerod.json @@ -0,0 +1,12 @@ +{ + "version": "v0.11.5.0", + "source_commit": "b22382c55fa2a97cd70aca450026b8e2bb8711a6", + "checksum_evidence": "https://github.com/wownero/wownero/actions/runs/37402323531", + "checksum_signature": "The release tag build replaced the archives after SHA256SUMS.txt.asc was signed. These hashes match the successful tag build and current release metadata; that signature does not cover these archives.", + "archives": { + "wownero-x86_64-w64-mingw32-v0.11.5.0.zip": "46dffbd313de1df4667516acf23737f986eb2e131c06a8dde1d11dcff2eb06e2", + "wownero-aarch64-apple-darwin11-v0.11.5.0.tar.bz2": "bad8cc7f1c78d026e63dbe3ffc0f2175d50b986bbde3fc18826a04ff693866d4", + "wownero-x86_64-apple-darwin11-v0.11.5.0.tar.bz2": "ae562c90a10856fcdd183dc9530dff73e46ed6eece9fb8dcfaa77f1e07174787", + "wownero-x86_64-linux-gnu-v0.11.5.0.tar.bz2": "2d0cc62c24255d45c51936616559651378011501c55a8caadb9a125bb875849b" + } +} diff --git a/src/assets/qml/mining.qml b/src/assets/qml/mining.qml index e105bcda..29c76c41 100644 --- a/src/assets/qml/mining.qml +++ b/src/assets/qml/mining.qml @@ -606,7 +606,7 @@ Rectangle { horizontalAlignment: Text.AlignHCenter color: "#f2dcb4" font.pointSize: 9 - text: "Mining needs your own node.\nYou are connected to a remote one — " + + text: "Mining needs your own node.\nYou are connected to a remote one. " + "start the built-in node or point the wallet at a local daemon " + "under Settings to mine." } diff --git a/src/dialog/AboutDialog.ui b/src/dialog/AboutDialog.ui index 5d17205f..35555dcd 100644 --- a/src/dialog/AboutDialog.ui +++ b/src/dialog/AboutDialog.ui @@ -137,7 +137,7 @@ - codeberg.org/wownero/wowlet + github.com/wownero/wowlet Qt::TextInteractionFlag::LinksAccessibleByMouse|Qt::TextInteractionFlag::TextSelectableByMouse