Compare commits

...
23 Commits
Author SHA1 Message Date
John Winter MurphyandGitHub 64529590a5 Merge pull request #5 from wownero/fix/appimage-libcom-err-20261024
fix(appimage): bundle libcom_err runtime
2026-10-24 12:10:01 -04:00
such-gitea 3c5aaed087 fix(appimage): bundle libcom_err runtime 2026-10-24 12:09:17 -04:00
John Winter MurphyandGitHub 020b177bd1 Merge pull request #4 from wownero/fix/macos-signing-keychain-515
fix(release): restore Mac signing keychain search
2026-10-17 08:29:38 -04:00
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
such-gitea ec8bdf4dfe fix(wallet): include the Wownero 0.11.5.0 security library 2026-10-17 06:03:15 -04:00
such-gitea 34bdecf5ac fix(release): embed the verified Wownero 0.11.5.0 daemon 2026-10-17 05:59:54 -04:00
such-gitea d9cd8e328e fix(release): check macOS signing inputs before compilation 2026-10-17 05:52:26 -04:00
such-gitea 998684c1a7 ci(release): bind private Wowlet candidates to reviewed source 2026-10-17 05:51:46 -04:00
such-gitea bf455e06f1 chore(release): prepare Wowlet 5.1.5 2026-10-17 05:46:57 -04:00
John Winter MurphyandGitHub 4e3f74a38a fix: use Wownero support contacts
Use Wownero support contacts and preserve them when regenerating bundled guides. Run lightweight source checks on GitHub; release builds remain in the private Builds system.
2026-10-17 05:32:51 -04:00
such-gitea 4050d51760 ci: keep GitHub source checks separate from private release builds 2026-10-17 05:15:39 -04:00
such-gitea 8dacc3ba5a ci: gate Wowlet documentation changes 2026-10-16 14:43:04 -04:00
such-gitea 66c16d152d fix(docs): route Wowlet support to Wownero contacts 2026-10-16 14:27:59 -04:00
jwinterm f4bbfd49ad fix(ci): publish the signed Windows zips to the tag's release
The Linux and macOS jobs already attach their tag-build output to the release,
because this Gitea reports zero artifacts for every run through its REST API and
its web download route answers "Not found." under every auth style. The Windows
job was left out, so even a green signed build produced an exe nobody could
fetch back.

The step runs after the Authenticode, RFC 3161 timestamp and signer checks, so
an unsigned or untimestamped binary can never reach a release. Both archives go
up: the signed one users should download, and the unsigned one that stays
byte-reproducible for verifiers.

curl.exe rather than Invoke-WebRequest, because `shell: powershell` is Windows
PowerShell 5.1 and its Invoke-WebRequest has no -Form for multipart uploads.
Fail-safe like the others: no token, no resolvable release or a non-201 upload
reports itself and exits 0 rather than reddening a build that produced a
correctly signed binary.
2026-09-13 18:09:17 -04:00
jwinterm 142b79ee27 fix(depends): tell the consuming build where the host Qt is
A cross Qt carrying qtdeclarative makes find_package(Qt6) demand a host Qt,
because QML needs host-side tools: qmlcachegen, qmlimportscanner and
qmltyperegistrar. depends already builds one. native_qt installs it to
native/qt-host, and qt.mk passes that path to the cross Qt's own configure.

What nobody passed it to was wowlet. Qt6Config re-runs the host-path setup when
our project calls find_package, finds nothing, and stops with "To use a
cross-compiled Qt, please set the QT_HOST_PATH cache variable". The Windows
cross build has failed that way on every tag since qtdeclarative landed in
June, which is why 5.1.4 shipped macOS and Linux but no exe.

Stating it in the toolchain rather than in the workflow fixes every cross
target and anyone building locally through depends, and the built-cache key
already hashes this file, so the stale mingw tree is rebuilt rather than
restored without the host Qt.
2026-09-13 18:06:55 -04:00
jwinterm a43e9fe122 fix(ci): publish tag builds to the release, where they can be fetched back
Run artifacts upload fine, but this Gitea reports zero artifacts for every run
through its REST API, and the web download route answers "Not found." under
token, bearer and basic auth alike. A signed build was therefore unreachable
from anywhere except the browser session that produced it.

Release assets do work: create, upload, list and download all round-trip with
an ordinary token, so tag builds now attach their output to the tag's release
as well as to the run. Retrieval must use browser_download_url; the asset-id
API path returns the asset's JSON metadata even when asked for octet-stream.

The step is fail-safe on purpose. With no token, no resolvable release, or a
non-201 upload it explains itself and exits 0 rather than turning a green
build red. The macOS upload also gains if-no-files-found, without which an
empty publish passes silently.
2026-09-13 13:18:22 -04:00
jwinterm ce84b1d062 fix(ci): scope the embed extracts to the run, not to a shared /tmp
ubuntu-latest and windows-latest both resolve to the same host in host mode,
and both jobs began with `rm -rf /tmp/wownerod-embed`. Dispatched together for
5.1.4, the Windows fetch deleted the Linux job's binary in the eight seconds
between its own `ls` showing the file present and cmake configuring, so the
Linux build died on "WOWNEROD_DIR was specified but the wownerod binary could
not be found" while the identical workflow had passed minutes earlier when it
ran alone.

Both jobs now extract under /tmp/wowlet-$GITHUB_RUN_ID, so concurrent runs stop
sharing state. macOS is left alone: it runs on its own host, it never rm -rf's
the directory, and it is the one platform currently building green.
2026-09-13 13:13:35 -04:00
jwinterm 0e6113729d chore(release): 5.1.4
Carries the binary rename: the shipped executable, the macOS bundle and the
Linux desktop entry are all wowlet now, where 5.1.3 still shipped feather.
2026-09-13 13:03:50 -04:00
such-gitea 27d7b346c0 fix(build): ship the binary as wowlet, not feather 2026-09-13 10:57:32 -04:00
jwinterm ea9c73826b fix(build): ship the binary as wowlet, not feather
The CMake target keeps its upstream name so the twenty-odd target_* calls stay
put, but OUTPUT_NAME now produces build/bin/wowlet, and on macOS a wowlet.app
whose CFBundleExecutable matches it.

Linux and Windows already renamed the artifact as they copied it, so their cp
lines just move to the new path. macOS copied the whole bundle instead, so it
shipped Contents/MacOS/feather with a Feather name in the Info.plist, which is
what anyone installing it actually saw. The Linux install rules had the same
problem one level down: they installed feather.desktop, which sets Exec=feather
and Name=Feather Wallet, while the correct wowlet.desktop sat unused beside it.

CFBundleIdentifier moves to org.wownero.wowlet. Nothing in signing or
notarization reads it, but macOS keys per-app state by bundle id, so an existing
install starts with fresh window state.
2026-09-13 10:49:00 -04:00
35 changed files with 1057 additions and 271 deletions
+38 -23
View File
@@ -1,7 +1,7 @@
name: linux appimage (gitea self-hosted) name: linux appimage (gitea self-hosted)
# Runs ONLY on Gitea (GitHub ignores .gitea/workflows), in the private Builds # Runs ONLY on Gitea (GitHub ignores .gitea/workflows), in the private Builds
# repository. Gitea is master; GitHub is a mirror and a release surface. # repository. GitHub wownero/wowlet owns source and release publication.
# #
# Unlike the Windows lane, which builds a static toolchain of its own through # Unlike the Windows lane, which builds a static toolchain of its own through
# contrib/depends, the x86_64 AppImage links against the host's Qt6. Those # contrib/depends, the x86_64 AppImage links against the host's Qt6. Those
@@ -11,7 +11,16 @@ name: linux appimage (gitea self-hosted)
# #
# Dispatch-only. The runner is capacity 1 and shared with the fleet gates. # Dispatch-only. The runner is capacity 1 and shared with the fleet gates.
on: [workflow_dispatch] on:
workflow_dispatch:
inputs:
expected_sha:
description: Exact reviewed Builds commit
required: true
type: string
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
jobs: jobs:
appimage: appimage:
@@ -39,33 +48,33 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
submodules: recursive submodules: recursive
ref: ${{ github.sha }}
- name: fetch wownerod to embed - name: Verify exact reviewed source
run: python3 -B contrib/release/check_dispatch.py
- name: fetch verified wownerod to embed
run: | run: |
rm -rf /tmp/wownerod-embed && mkdir -p /tmp/wownerod-embed python3 -B contrib/release/fetch_wownerod.py \
curl -fL -o /tmp/wownero.tar.bz2 \ --target x86_64-linux-gnu \
https://github.com/wownero/wownero-codeberg-mirror/releases/download/v0.11.4.0/wownero-x86_64-linux-gnu-v0.11.4.0.tar.bz2 --destination "/tmp/wowlet-${GITHUB_RUN_ID}/wownerod-embed"
rm -rf /tmp/wow-x && mkdir -p /tmp/wow-x
tar xjf /tmp/wownero.tar.bz2 -C /tmp/wow-x
find /tmp/wow-x -name wownerod -type f -exec cp {} /tmp/wownerod-embed/ \;
chmod +x /tmp/wownerod-embed/wownerod
ls -la /tmp/wownerod-embed
- name: fetch tor to embed - name: fetch tor to embed
run: | run: |
rm -rf /tmp/tor-embed && mkdir -p /tmp/tor-embed EMBED="/tmp/wowlet-${GITHUB_RUN_ID:-local}"
curl -fL -o /tmp/teb.tgz \ rm -rf "$EMBED/tor-embed" && mkdir -p "$EMBED/tor-embed"
curl -fL -o "$EMBED/teb.tgz" \
https://archive.torproject.org/tor-package-archive/torbrowser/15.0/tor-expert-bundle-linux-x86_64-15.0.tar.gz https://archive.torproject.org/tor-package-archive/torbrowser/15.0/tor-expert-bundle-linux-x86_64-15.0.tar.gz
rm -rf /tmp/teb && mkdir -p /tmp/teb && tar xzf /tmp/teb.tgz -C /tmp/teb rm -rf "$EMBED/teb" && mkdir -p "$EMBED/teb" && tar xzf "$EMBED/teb.tgz" -C "$EMBED/teb"
cp /tmp/teb/tor/tor /tmp/teb/tor/libssl.so.3 /tmp/teb/tor/libevent-2.1.so.7 \ cp "$EMBED/teb/tor/tor" "$EMBED/teb/tor/libssl.so.3" "$EMBED/teb/tor/libevent-2.1.so.7" \
/tmp/teb/tor/libcrypto.so.3 /tmp/tor-embed/ "$EMBED/teb/tor/libcrypto.so.3" "$EMBED/tor-embed/"
# The expert bundle's tor carries no rpath; point it and its libs at # The expert bundle's tor carries no rpath; point it and its libs at
# $ORIGIN so the embedded copy resolves them from the extract dir on # $ORIGIN so the embedded copy resolves them from the extract dir on
# any distro. # any distro.
for f in tor libssl.so.3 libevent-2.1.so.7 libcrypto.so.3; do for f in tor libssl.so.3 libevent-2.1.so.7 libcrypto.so.3; do
patchelf --set-rpath '$ORIGIN' /tmp/tor-embed/$f patchelf --set-rpath '$ORIGIN' "$EMBED/tor-embed/$f"
done done
chmod +x /tmp/tor-embed/tor chmod +x "$EMBED/tor-embed/tor"
- name: build - name: build
run: | run: |
@@ -73,15 +82,16 @@ jobs:
# binary, or TorManager::unpackBins() sees embedded > unpacked and # binary, or TorManager::unpackBins() sees embedded > unpacked and
# rewrites Tor on every launch. The bundle's release number (15.0) is # rewrites Tor on every launch. The bundle's release number (15.0) is
# not its tor version, so derive it. # not its tor version, so derive it.
TORVER=$(/tmp/tor-embed/tor --version | grep -oE '[0-9]+(\.[0-9]+){3}' | head -1) EMBED="/tmp/wowlet-${GITHUB_RUN_ID:-local}"
TORVER=$("$EMBED/tor-embed/tor" --version | grep -oE '[0-9]+(\.[0-9]+){3}' | head -1)
echo "embedded tor version: $TORVER" echo "embedded tor version: $TORVER"
# ARCH=x86-64, never native: the build host's CPU is newer than many # ARCH=x86-64, never native: the build host's CPU is newer than many
# users' and native bakes in unsupported instructions -> SIGILL. # users' and native bakes in unsupported instructions -> SIGILL.
cmake -S . -B build \ cmake -S . -B build \
-DWITH_SCANNER=OFF \ -DWITH_SCANNER=OFF \
-DARCH=x86-64 \ -DARCH=x86-64 \
-DWOWNEROD_DIR=/tmp/wownerod-embed \ -DWOWNEROD_DIR="$EMBED/wownerod-embed" \
-DTOR_DIR=/tmp/tor-embed \ -DTOR_DIR="$EMBED/tor-embed" \
-DTOR_VERSION="$TORVER" -DTOR_VERSION="$TORVER"
cmake --build build -j6 cmake --build build -j6
ls -la build/bin/ ls -la build/bin/
@@ -91,7 +101,7 @@ jobs:
rm -rf AppDir rm -rf AppDir
mkdir -p AppDir/usr/bin AppDir/usr/share/applications \ mkdir -p AppDir/usr/bin AppDir/usr/share/applications \
AppDir/usr/share/icons/hicolor/256x256/apps AppDir/usr/share/icons/hicolor/256x256/apps
cp build/bin/feather AppDir/usr/bin/wowlet cp build/bin/wowlet AppDir/usr/bin/wowlet
chmod +x AppDir/usr/bin/wowlet chmod +x AppDir/usr/bin/wowlet
cp src/assets/wowlet.desktop AppDir/usr/share/applications/wowlet.desktop cp src/assets/wowlet.desktop AppDir/usr/share/applications/wowlet.desktop
cp src/assets/images/appicons/256x256.png \ cp src/assets/images/appicons/256x256.png \
@@ -139,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
+47 -71
View File
@@ -12,9 +12,18 @@ name: macos build (gitea self-hosted)
# APPLE_CERTIFICATE (b64 Developer ID Application .p12), # APPLE_CERTIFICATE (b64 Developer ID Application .p12),
# APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY, APPLE_TEAM_ID, # APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY, APPLE_TEAM_ID,
# APPLE_API_ISSUER, APPLE_API_KEY, APPLE_API_KEY_P8_BASE64 (notarytool). # 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] on:
workflow_dispatch:
inputs:
expected_sha:
description: Exact reviewed Builds commit
required: true
type: string
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
jobs: jobs:
build-macos: build-macos:
@@ -31,30 +40,36 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
submodules: recursive submodules: recursive
ref: ${{ github.sha }}
- name: install dependencies (brew) - name: Verify exact reviewed source
run: | run: python3 -B contrib/release/check_dispatch.py
export PATH="/opt/homebrew/bin:$PATH"
set +o pipefail # yes(1) takes SIGPIPE when brew has nothing to ask
export NONINTERACTIVE=1 HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_INSTALL_UPGRADE=1
yes | brew install --quiet qt libsodium libzip qrencode unbound cmake boost hidapi openssl expat protobuf pkgconfig zxing-cpp
- name: fetch wownerod to embed - name: Check signing inputs before compiling
run: python3 -B contrib/release/sign_macos.py --check-inputs
- name: Verify Fleet-managed build dependencies
run: | run: |
set -e set -e
ARCH=$(uname -m) export PATH="/opt/homebrew/bin:$PATH"
case "$ARCH" in for formula in qt libsodium libzip qrencode unbound cmake boost hidapi openssl expat protobuf zxing-cpp; do
arm64) WOWTAR=wownero-aarch64-apple-darwin11-v0.11.4.0.tar.bz2 ;; brew --prefix "$formula" >/dev/null || {
*) WOWTAR=wownero-x86_64-apple-darwin11-v0.11.4.0.tar.bz2 ;; echo "Missing Fleet-managed build dependency: $formula" >&2
exit 1
}
done
- name: fetch verified wownerod to embed
run: |
set -e
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 esac
mkdir -p /tmp/wownerod-embed /tmp/wow-extract EMBED="/tmp/wowlet-${GITHUB_RUN_ID}/wownerod-embed"
curl -L -o /tmp/wownero.tar.bz2 \ python3 -B contrib/release/fetch_wownerod.py --target "$target" --destination "$EMBED"
"https://github.com/wownero/wownero-codeberg-mirror/releases/download/v0.11.4.0/$WOWTAR" echo "WOWNEROD_DIR=$EMBED" >> "$GITHUB_ENV"
# /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
- name: fetch tor to embed - name: fetch tor to embed
run: | run: |
@@ -88,7 +103,7 @@ jobs:
export CPATH="$(brew --prefix)/include" export CPATH="$(brew --prefix)/include"
export LIBRARY_PATH="$(brew --prefix)/lib" export LIBRARY_PATH="$(brew --prefix)/lib"
mkdir -p build && cd build 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)" cmake --build . -j "$(sysctl -n hw.ncpu)"
- name: package app bundle - name: package app bundle
@@ -184,47 +199,8 @@ jobs:
ls -la "$FW" | grep -iE "webp|sharpyuv|brotli" || true ls -la "$FW" | grep -iE "webp|sharpyuv|brotli" || true
ls -la dist/ ls -la dist/
- name: sign and notarize (skipped when secrets absent) - name: Sign and notarize the private candidate
run: | run: python3 -B contrib/release/sign_macos.py
set -e
cd build
if [ -z "$APPLE_CERTIFICATE" ]; then
echo "APPLE_CERTIFICATE not set — shipping UNSIGNED build"
exit 0
fi
KC=/tmp/wowlet-ci.keychain-db
KCPW=$(/usr/bin/uuidgen)
echo "$KCPW" > /tmp/wowlet-ci-kcpw # for the cleanup step only
security create-keychain -p "$KCPW" "$KC"
security set-keychain-settings -lut 3600 "$KC"
security unlock-keychain -p "$KCPW" "$KC"
printf '%s' "$APPLE_CERTIFICATE" | base64 -d > /tmp/dev_id.p12
security import /tmp/dev_id.p12 -k "$KC" -P "$APPLE_CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "$KCPW" "$KC" >/dev/null
security list-keychains -d user -s "$KC" login.keychain-db
# Sign inside-out: every Mach-O NOT inside a .framework (embedded
# wownerod/tor, the bundled webp/sharpyuv/brotli dylibs, and the
# image-format plugins whose signatures install_name_tool just
# invalidated — keyed on `file`, not exec bit, since plugins/dylibs
# aren't +x), then each Qt framework as a unit, then the bundle.
SIGN() { codesign --force --options runtime --timestamp --keychain "$KC" \
--sign "$APPLE_SIGNING_IDENTITY" "$@"; }
find dist/Wowlet.app -type f ! -path "*/*.framework/*" \
| while read -r f; do file "$f" | grep -q Mach-O && SIGN "$f" || true; done
find dist/Wowlet.app/Contents/Frameworks -maxdepth 1 -name "*.framework" 2>/dev/null \
| while read -r fw; do SIGN "$fw"; done
SIGN dist/Wowlet.app
codesign --verify --deep --strict dist/Wowlet.app
printf '%s' "$APPLE_API_KEY_P8_BASE64" | base64 -d > /tmp/notary.p8
ditto -c -k --keepParent dist/Wowlet.app /tmp/wowlet-notarize.zip
xcrun notarytool submit /tmp/wowlet-notarize.zip \
--key /tmp/notary.p8 --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" \
--wait --timeout 30m
xcrun stapler staple dist/Wowlet.app
echo "signed + notarized + stapled"
- name: zip artifact - name: zip artifact
run: | run: |
@@ -233,17 +209,17 @@ 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: cleanup signing material - name: Record signed candidate source and hashes
if: always() run: python3 -B contrib/release/write_receipt.py --platform macos --file build/wowlet-macos-$(uname -m).zip --output build/wowlet-macos-receipt.json
run: |
if [ -f /tmp/wowlet-ci-kcpw ]; then
security delete-keychain /tmp/wowlet-ci.keychain-db || true
fi
rm -f /tmp/dev_id.p12 /tmp/notary.p8 /tmp/wowlet-ci-kcpw /tmp/wowlet-notarize.zip
- 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
# build can appear to publish while shipping nothing.
if-no-files-found: error
+73 -12
View File
@@ -1,7 +1,7 @@
name: windows build + sign (gitea self-hosted) name: windows build + sign (gitea self-hosted)
# Runs ONLY on Gitea (GitHub ignores .gitea/workflows), in the private Builds # Runs ONLY on Gitea (GitHub ignores .gitea/workflows), in the private Builds
# repository. Gitea is master; GitHub is a mirror and a release surface. # repository. GitHub wownero/wowlet owns source and release publication.
# #
# Two jobs on two different machines, and that split is the whole point: # Two jobs on two different machines, and that split is the whole point:
# #
@@ -20,9 +20,16 @@ name: windows build + sign (gitea self-hosted)
# #
# Dispatch-only. Both runners are capacity 1 and shared with other lanes. # Dispatch-only. Both runners are capacity 1 and shared with other lanes.
on: [workflow_dispatch] on:
workflow_dispatch:
inputs:
expected_sha:
description: Exact reviewed Builds commit
required: true
type: string
env: env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
HOST: x86_64-w64-mingw32 HOST: x86_64-w64-mingw32
jobs: jobs:
@@ -52,6 +59,10 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
submodules: recursive submodules: recursive
ref: ${{ github.sha }}
- name: Verify exact reviewed source
run: python3 -B contrib/release/check_dispatch.py
# depends is the slow part (static Qt6/boost/openssl/tor from source). # depends is the slow part (static Qt6/boost/openssl/tor from source).
# Cache the source tarballs and the compiled packages so only a cold run # Cache the source tarballs and the compiled packages so only a cold run
@@ -96,15 +107,11 @@ jobs:
path: contrib/depends/built path: contrib/depends/built
key: depends-built-mingw32-${{ hashFiles('contrib/depends/packages/*', 'contrib/depends/hosts/*', 'contrib/depends/toolchain.cmake.in') }} 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: | run: |
rm -rf /tmp/wownerod-embed && mkdir -p /tmp/wownerod-embed python3 -B contrib/release/fetch_wownerod.py \
curl -fL -o /tmp/wownero-win.zip \ --target x86_64-w64-mingw32 \
https://github.com/wownero/wownero-codeberg-mirror/releases/download/v0.11.4.0/wownero-x86_64-w64-mingw32-v0.11.4.0.zip --destination "/tmp/wowlet-${GITHUB_RUN_ID}/wownerod-embed"
# -j flattens the versioned subdir so wownerod.exe is a DIRECT child
# (WownerodQrcGenerator globs ${WOWNEROD_DIR}/* non-recursively).
unzip -o -j /tmp/wownero-win.zip 'wownero-x86_64-w64-mingw32-v0.11.4.0/wownerod.exe' -d /tmp/wownerod-embed
ls -la /tmp/wownerod-embed
- name: build wowlet.exe - name: build wowlet.exe
run: | run: |
@@ -118,7 +125,7 @@ jobs:
-DCHECK_UPDATES=OFF \ -DCHECK_UPDATES=OFF \
-DTOR_INSTALLED=On \ -DTOR_INSTALLED=On \
-DFEATHER_TARGET_TRIPLET="$HOST" \ -DFEATHER_TARGET_TRIPLET="$HOST" \
-DWOWNEROD_DIR=/tmp/wownerod-embed \ -DWOWNEROD_DIR="/tmp/wowlet-${GITHUB_RUN_ID:-local}/wownerod-embed" \
.. ..
cmake --build . -j6 cmake --build . -j6
ls -la bin/ ls -la bin/
@@ -129,11 +136,14 @@ jobs:
# archive contains the signed binary. # archive contains the signed binary.
run: | run: |
mkdir -p stage/tor mkdir -p stage/tor
cp build/bin/feather.exe stage/wowlet.exe cp build/bin/wowlet.exe stage/wowlet.exe
cp "contrib/depends/$HOST/Tor/tor.exe" stage/tor/tor.exe cp "contrib/depends/$HOST/Tor/tor.exe" stage/tor/tor.exe
(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
@@ -149,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: |
@@ -258,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:
@@ -265,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
+15
View File
@@ -0,0 +1,15 @@
name: Wowlet verify
on:
push:
branches: [master]
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check support guides and release source admission
run: make check
+13 -2
View File
@@ -1,6 +1,7 @@
name: ci/gh-actions/build name: ci/gh-actions/build
on: [push, pull_request, workflow_dispatch] # Legacy builds are manual only. Release artifacts are built in Builds/wowlet.
on: [workflow_dispatch]
# NOTE (wownero-rebase branch): the Arch + macOS jobs from upstream Feather's build.yml are # NOTE (wownero-rebase branch): the Arch + macOS jobs from upstream Feather's build.yml are
# temporarily disabled to get a fast, cheap iteration loop while bringing up the build against the # temporarily disabled to get a fast, cheap iteration loop while bringing up the build against the
@@ -21,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
@@ -76,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).
# #
+16
View File
@@ -0,0 +1,16 @@
name: source checks
on: [push, pull_request]
permissions:
contents: read
jobs:
docs:
name: Wowlet documentation gate
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check generated documentation and support contacts
run: make check
+2 -1
View File
@@ -1,6 +1,7 @@
name: ci/gh-actions/guix name: ci/gh-actions/guix
on: [push, pull_request] # Legacy builds are manual only. Release artifacts are built in Builds/wowlet.
on: [workflow_dispatch]
jobs: jobs:
cache-sources: cache-sources:
+1 -1
View File
@@ -1,6 +1,6 @@
[submodule "monero"] [submodule "monero"]
path = monero path = monero
url = https://codeberg.org/wownero/wownero.git url = https://github.com/wownero/wownero.git
branch = gui branch = gui
[submodule "src/third-party/polyseed"] [submodule "src/third-party/polyseed"]
path = src/third-party/polyseed path = src/third-party/polyseed
+1 -1
View File
@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.18) cmake_minimum_required(VERSION 3.18)
project(feather project(feather
VERSION "5.1.3" VERSION "5.1.5"
DESCRIPTION "A free Monero desktop wallet" DESCRIPTION "A free Monero desktop wallet"
LANGUAGES CXX C ASM LANGUAGES CXX C ASM
) )
+5 -1
View File
@@ -13,4 +13,8 @@ clean:
DEFAULT_GOAL := default DEFAULT_GOAL := default
default: build default: build
.PHONY: default build attest verify clean check:
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s contrib/docs/tests -v
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s contrib/release/tests -v
.PHONY: default build attest verify clean check
+28 -44
View File
@@ -1,60 +1,44 @@
# Feather Wallet # Wowlet
Feather is a free Monero desktop wallet for Linux, Tails, macOS and Windows. It is written in C++ with the Qt framework. Wowlet is a Wownero desktop wallet for Linux, macOS and Windows, built with C++ and
Qt. It is based on [Feather Wallet](https://github.com/feather-wallet/feather).
- **easy-to-use**, **small** and **fast** - Feather runs well on any modern hardware, including virtual machines and live operating systems.
- **beginner friendly**, but also caters to advanced Monero users by providing a [feature set](https://docs.featherwallet.org/guides/features) that is on par with the official CLI.
- ships with **sane defaults** that suit most users, but can also be configured for high or uncommon threat models.
- serves as a testing grounds for **experimental features** that may later be adopted in the reference wallets.
## Download ## Download
You can download Feather from **[featherwallet.org](https://featherwallet.org/download/)** or **[GitHub](https://github.com/feather-wallet/feather/releases)**. Download Wowlet and its signed release hashes from
[Wowlet releases](https://github.com/wownero/wowlet/releases).
If you need help installing Feather, check the [installation documentation](https://docs.featherwallet.org/). ## Support
We recommend that you verify downloads with GPG. Releases are signed with our [release signing key](https://docs.featherwallet.org/guides/release-signing-key). The fingerprint is: - Matrix: [#wownero-gen:wowne.ro](https://matrix.to/#/#wownero-gen:wowne.ro)
- IRC: `#wownero` on [OFTC](https://www.oftc.net/), or use
[webchat](https://webchat.oftc.net/?randomnick=1&channels=wownero)
- Email: [wownero@wownero.org](mailto:wownero@wownero.org)
- Bug reports: [Wowlet issues](https://github.com/wownero/wowlet/issues)
``` If nobody replies immediately, stay in the room and give volunteers time to respond.
8185 E158 A333 30C7 FD61 BC0D 1F76 E155 CEFB A71C Include your Wowlet version, operating system, reproduction steps and any error
``` message. Never share your wallet seed, private keys, password or wallet files.
Windows releases are code-signed for free by [SignPath.io](https://about.signpath.io/), certificate by [SignPath Foundation](https://signpath.org/). Report vulnerabilities privately using [SECURITY.md](SECURITY.md).
## Resources
* [Official Site](https://featherwallet.org)
* [Documentation](https://docs.featherwallet.org)
* [Git Repository](https://github.com/feather-wallet/feather)
* [Matrix](https://matrix.to/#/#feather:monero.social)
* IRC: `#feather` on [OFTC](https://www.oftc.net/)
* Mail: dev@featherwallet.org
If you need help with your wallet, please contact us via Matrix or IRC.
If you don't have an IRC client, you can join the room via [webchat](https://webchat.oftc.net/?randomnick=1&channels=feather).
If you don’t receive a response immediately please idle in the room.
## Release Builds
To learn how to run a bootstrappable release build, see: [contrib/guix/README.md](https://github.com/feather-wallet/feather/blob/master/contrib/guix/README.md)
For release attestations, see the [feather-sigs](http://github.com/feather-wallet/feather-sigs) repo.
For release policy, see: [RELEASE.md](https://github.com/feather-wallet/feather/blob/master/RELEASE.md)
## Development ## Development
If you are looking to set up a development environment for Feather, see [HACKING.md](https://github.com/feather-wallet/feather/blob/master/HACKING.md). [GitHub](https://github.com/wownero/wowlet) is the canonical source repository.
Release builds run in our private Builds system. Signed release artifacts and
hashes are published on GitHub Releases. GitHub runs lightweight source checks;
its legacy build workflows require an explicit manual dispatch.
It is highly recommended that you join our Matrix or IRC channel if you are hacking on Feather. The application includes an offline documentation browser under Help. Its guides
Idling in this channel is the best way to stay updated on best practices and new developments. are generated by `contrib/docs/generate.py` from the Feather documentation submodule.
Wowlet-specific guides in `contrib/docs/overrides` take precedence over upstream
guides with the same filename. Edit those sources, then regenerate; files under
`src/assets/docs` are generated and must not be edited directly.
For information on how Feather is maintained, see: [MAINTENANCE.md](https://github.com/feather-wallet/feather/blob/master/MAINTENANCE.md) Run the documentation regression checks with `make check`. The inherited build
instructions are in [HACKING.md](HACKING.md).
To report a security vulnerability, see: [SECURITY.md](https://github.com/feather-wallet/feather/blob/master/SECURITY.md)
## License ## License
Feather is free and open-source software, [licensed under BSD-3](https://raw.githubusercontent.com/feather-wallet/feather/master/LICENSE). Wowlet is free and open-source software under [BSD-3-Clause](LICENSE).
Upstream copyright notices are retained in the source.
Copyright (c) 2020-2025, The Monero Project
+40 -45
View File
@@ -1,46 +1,41 @@
# Release process # Wowlet release process
- Before a minor release: GitHub `wownero/wowlet` owns source and public releases. Private `Builds/wowlet`
- Update `src/assets/restore_heights_monero_{mainnet,stagenet}.txt` is the build ingress on our hardware. Fleet owns dispatch and publication plans.
- To obtain values, run `contrib/generate-restore-heights/heights.py` A source merge, a build, a code signature and a published release are separate outcomes.
- Update default node lists in `src/assets/nodes.json`
- Bump `openssl`, `qt`, `tor_*` packages in `contrib/depends/packages` 1. Prepare and review a GitHub pull request with the version, wallet-library pin,
- Update or patch any statically linked dependencies that have known vulnerabilities support documentation and release inputs. Publish a new library commit to its
- Run `feather-utils/depends/vulns.py` to check canonical repository before pinning it. Keep source and dependency revisions exact.
- Rebase on top of latest Monero version 2. `make check` runs the cheap source checks. Run the private Linux, Windows and
- Update `src/assets/ack.txt` macOS workflows through reviewed Fleet admission with `expected_sha` equal to
- Update documentation (`feather-wallet/feather-docs`) and `external/feather-docs` submodule the protected Builds commit. Preserve canonical source and Builds commit identities
- Update the version number in `CMakeLists.txt` separately. Do not run release builds on GitHub.
- A hardfork-ready release must bump major version 3. The build helpers verify embedded Wownero archives against `contrib/release/wownerod.json`.
- Create an annotated tag (`git tag x.x.x -a`) That file records the reviewed checksum provenance. Do not describe an unsigned
- Tag must match version in `CMakeLists.txt` checksum list as a PGP verification.
- Only commits that update the version number in `CMakeLists.txt` may be tagged 4. Collect artifacts by their run and artifact identities. Require a Linux AppImage,
- Push the master branch and tags macOS archive, signed Windows archive and unsigned Windows archive. Check archive
- `git push --tags origin master` paths, bundled runtimes and packaging requirements. Never select an artifact by size.
- Run `guix` builds in a clean repo: 5. Require Developer ID signing, accepted notarization and stapling for macOS.
- ```bash Require valid, timestamped Such Software LLC Authenticode signing for Windows.
git clone https://github.com/feather-wallet/feather.git The EV token stays on its signing host; CI never receives its password or key.
cd feather 6. Record any manual wallet checks actually performed. Manual testing and independent
git checkout <TAG> reproducibility are separate evidence; successful builds do not prove either.
git submodule update --init --recursive The unsigned Windows archive is provided for reproduction, without claiming an
./contrib/guix/guix-build independent reproduction occurred.
``` 7. Create `hashes.txt` over the four final archives. Sign it on the owner's signing
- Alternatively, run `feather-utils/guix/run-build.sh` machine with the existing release key `9F9930300530DF724761B7055A881347310EB79F`.
- Attest the builds (`contrib/guix/guix-attest`) and PR to `feather/feather-sigs`. Verify the detached signature and all hashes before publication. Never transfer
- Wait until at least one other person has independently verified reproducibility the private key or put a passphrase in a command, environment or log.
- If builds are not reproducible: fix any reproducibility defects and bump patch version. Do not sign or release non-reproducible builds. 8. Create an annotated `vX.Y.Z` tag from the exact reviewed canonical source, matching
- Sign release artifacts and hashlists. `CMakeLists.txt`. Use Fleet's reviewed publication plan to upload the four archives,
- Transfer files in `guix-build-x.x.x/output` to release signing machine `hashes.txt` and `hashes.txt.asc` to GitHub Releases. Refuse replacement of existing
- Run `feather-utils/release/make-release.sh` tags or assets; recover partial publication from retained intent.
- Update the site (`feather-wallet/feather-site`) 9. Download the public assets again and verify their hashes and PGP signature. Record
- Run the `feather-utils/site/bump-version.py` script to create a template commit. the tag, source commit, Builds commit, run identities, signing evidence and public
- Edit the changelog in `content/changelog` URL in the private journal. Release notes should say when security fixes are included.
- 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` Additional architectures and Guix builds remain separate lanes. Do not claim a
- Upload releases, signatures and signed hashlists. platform is released merely because its workflow exists. Announcements are an
- Follow the directory structure defined in `MainWindow::onShowUpdateCheck`. owner action, separate from publishing the release files.
- 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
+7 -39
View File
@@ -2,43 +2,11 @@
## Reporting a Vulnerability ## Reporting a Vulnerability
Please do not open an issue to report security issues. Report Wowlet security vulnerabilities privately to
[wownero@wownero.org](mailto:wownero@wownero.org).
Please do not disclose them in public issues or community chat before the
maintainers have had time to investigate.
To report a vulnerability email to dev@featherwallet.org Include the affected Wowlet version, operating system, expected impact and steps
to reproduce the issue. Use a disposable wallet for any reproduction and never
The following keys may be used to communicate sensitive information to developers: send a real wallet seed, private keys, password or wallet files.
| Name | Fingerprint |
|---------|---------------------------------------------------|
| tobtoht | E87B D921 CDD8 85C9 D78A 38C5 E45B 10DD 027D 2472 |
Public keys can be found in [`utils/pubkeys`](utils/pubkeys).
## Bug Bounty Program
A bounty may be rewarded to a vulnerability report **if and only if** the issue can result in **a loss of funds**.
You must describe a **plausible scenario** in which a loss of funds can occur (or has occurred) that **isn't solely attributable to user error**.
Only **the code** of the **latest tagged release** of **[this repository](https://github.com/feather-wallet/feather/)** is in scope.
**The bounty can only be rewarded in XMR**. The bounty amount for your report is determined by the maintainers and ranges from USD 150 to USD 3000 (in terms of XMR) and depends on the severity of the issue and other factors.
Clarifications on scope:
- The issue **must be present in a [signed](https://docs.featherwallet.org/guides/release-signing-key) [release build](https://github.com/feather-wallet/feather/blob/master/contrib/guix/README.md)**. Custom builds, including distribution packages, are out of scope.
- The developers **must be able to reproduce and fix the issue**. If the issue cannot be fixed **in our code** for any reason, it is out of scope.
- The live [websites](https://github.com/feather-wallet/feather-site/blob/master/mirrors.txt) and their repositories are out of scope.
- Loss of funds due to malware on the user's machine is out of scope.
- Memory imaging, including cold boot attacks, is out of scope.
- Social engineering against users is out of scope. This includes messages received from the [websocket server](https://docs.featherwallet.org/guides/websocket).
- Any form of coercion, physical or psychological, is out of scope.
- Vulnerabilities that are attributable to hardware are out of scope.
- If the issue was fixed in the `master` branch before we receive your report, it is invalid and not eligible for a bounty from this program.
- If the vulnerability involves binary exploitation, we may ask you to provide a proof of concept of secret key exfiltration.
- Vulnerabilities that are present in the monero submodule but were not introduced in patches made by the Feather developers must
be reported [upstream](https://github.com/monero-project/meta/blob/master/VULNERABILITY_RESPONSE_PROCESS.md) and are not eligible for a bounty from this program.
- Vulnerabilities that are present in any of our third-party dependencies must be reported upstream and are not eligible for a bounty from this program.
- Vulnerabilities that are present in [supported hardware wallets](https://docs.featherwallet.org/guides/hardware-wallet-support) must be reported upstream and are not eligible for a bounty from this program.
- A bounty will not be awarded if the reported vulnerability was already known. We may make an exception if you demonstrate that the severity of the issue was underestimated and no immediate fix was planned.
- If, during your research, you disrupt Feather's release infrastructure or services, or attempt to coerce its developers, you will not be awarded a bounty.
+1 -1
View File
@@ -5,4 +5,4 @@ echo $HERE
# Tails 5.8 sets QT_QPA_PLATFORM to wayland which breaks our app # Tails 5.8 sets QT_QPA_PLATFORM to wayland which breaks our app
export QT_QPA_PLATFORM=xcb export QT_QPA_PLATFORM=xcb
${APPDIR}/usr/bin/feather $* ${APPDIR}/usr/bin/wowlet $*
+13 -13
View File
@@ -5,29 +5,29 @@ unset SOURCE_DATE_EPOCH
# Manually create the AppImage (reproducibly) since linuxdeployqt is not able to create cross-compiled AppImages # Manually create the AppImage (reproducibly) since linuxdeployqt is not able to create cross-compiled AppImages
APPDIR="$PWD/feather.AppDir" APPDIR="$PWD/wowlet.AppDir"
mkdir -p "$APPDIR" mkdir -p "$APPDIR"
mkdir -p "$APPDIR/usr/share/applications/" mkdir -p "$APPDIR/usr/share/applications/"
mkdir -p "$APPDIR/usr/bin" mkdir -p "$APPDIR/usr/bin"
cp "src/assets/feather.desktop" "$APPDIR/usr/share/applications/feather.desktop" cp "src/assets/wowlet.desktop" "$APPDIR/usr/share/applications/wowlet.desktop"
cp "src/assets/feather.desktop" "$APPDIR/feather.desktop" cp "src/assets/wowlet.desktop" "$APPDIR/wowlet.desktop"
cp "src/assets/images/appicons/64x64.png" "$APPDIR/feather.png" cp "src/assets/images/appicons/64x64.png" "$APPDIR/wowlet.png"
cp "build/bin/feather" "$APPDIR/usr/bin/feather" cp "build/bin/wowlet" "$APPDIR/usr/bin/wowlet"
chmod +x "$APPDIR/usr/bin/feather" chmod +x "$APPDIR/usr/bin/wowlet"
cp "contrib/AppImage/AppRun" "$APPDIR/" cp "contrib/AppImage/AppRun" "$APPDIR/"
chmod +x "$APPDIR/AppRun" chmod +x "$APPDIR/AppRun"
find feather.AppDir/ -exec touch -h -a -m -t 202101010100.00 {} \; find wowlet.AppDir/ -exec touch -h -a -m -t 202101010100.00 {} \;
mksquashfs feather.AppDir feather.squashfs -comp zstd -info -root-owned -no-xattrs -noappend -fstime 0 mksquashfs wowlet.AppDir wowlet.squashfs -comp zstd -info -root-owned -no-xattrs -noappend -fstime 0
# mksquashfs writes a timestamp to the header # mksquashfs writes a timestamp to the header
printf '\x00\x00\x00\x00' | dd conv=notrunc of=feather.squashfs bs=1 seek=$((0x8)) printf '\x00\x00\x00\x00' | dd conv=notrunc of=wowlet.squashfs bs=1 seek=$((0x8))
rm -f feather.AppImage rm -f wowlet.AppImage
cat "${GITHUB_WORKSPACE:-/feather}/contrib/depends/${HOST}/runtime" >> feather.AppImage cat "${GITHUB_WORKSPACE:-/feather}/contrib/depends/${HOST}/runtime" >> wowlet.AppImage
cat feather.squashfs >> feather.AppImage cat wowlet.squashfs >> wowlet.AppImage
chmod a+x feather.AppImage chmod a+x wowlet.AppImage
+9
View File
@@ -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
+9
View File
@@ -43,6 +43,15 @@ SET(absl_ROOT @prefix@)
SET(NATIVE_BIN_PATH @prefix@/native/bin CACHE FILEPATH "path to native binaries" FORCE) SET(NATIVE_BIN_PATH @prefix@/native/bin CACHE FILEPATH "path to native binaries" FORCE)
SET(LRELEASE_PATH @prefix@/native/bin CACHE FILEPATH "path to lrelease" FORCE) SET(LRELEASE_PATH @prefix@/native/bin CACHE FILEPATH "path to lrelease" FORCE)
# A cross Qt that carries qtdeclarative makes find_package(Qt6) demand a HOST Qt,
# because QML needs host-side tools (qmlcachegen, qmlimportscanner,
# qmltyperegistrar). depends already builds one: native_qt installs to
# native/qt-host and qt.mk hands that path to the cross Qt's own configure. Our
# build never heard about it, so Qt6Config refused with "please set the
# QT_HOST_PATH cache variable" and the Windows cross build has failed since
# qtdeclarative landed. Same path, stated once more for the consuming project.
SET(QT_HOST_PATH @prefix@/native/qt-host CACHE PATH "host Qt for cross QML tooling")
if(NOT CMAKE_SYSTEM_NAME STREQUAL "Android") if(NOT CMAKE_SYSTEM_NAME STREQUAL "Android")
SET(LIBUNWIND_INCLUDE_DIR @prefix@/include) SET(LIBUNWIND_INCLUDE_DIR @prefix@/include)
SET(LIBUNWIND_LIBRARIES @prefix@/lib/libunwind.a) SET(LIBUNWIND_LIBRARIES @prefix@/lib/libunwind.a)
+6 -2
View File
@@ -2,6 +2,7 @@ import os
import glob import glob
DOCS_DIR = "external/feather-docs/content/_guides" DOCS_DIR = "external/feather-docs/content/_guides"
OVERRIDES_DIR = "contrib/docs/overrides"
OUT_DIR = "src/assets/docs" OUT_DIR = "src/assets/docs"
CATEGORY_MAP = { CATEGORY_MAP = {
@@ -21,9 +22,12 @@ outfiles = glob.glob(f"{OUT_DIR}/*.md")
for file in outfiles: for file in outfiles:
os.remove(file) os.remove(file)
files = glob.glob(f"{DOCS_DIR}/*.md") # Wowlet owns its support contacts. Local sources take precedence so regenerating
# the bundled docs cannot restore Feather's contact details.
files = {os.path.basename(path): path for path in glob.glob(f"{DOCS_DIR}/*.md")}
files.update({os.path.basename(path): path for path in glob.glob(f"{OVERRIDES_DIR}/*.md")})
for file in files: for file in sorted(files.values()):
with open(file) as f: with open(file) as f:
doc = f.read() doc = f.read()
+29
View File
@@ -0,0 +1,29 @@
---
title: "How to report an issue"
nav_title: "Report an issue"
category: "help"
---
For help with Wowlet, contact the Wownero community:
- Matrix: [#wownero-gen:wowne.ro](https://matrix.to/#/#wownero-gen:wowne.ro)
- IRC: `#wownero` on [OFTC](https://www.oftc.net/)
- Email: [wownero@wownero.org](mailto:wownero@wownero.org)
- Bug reports: [Wowlet issues](https://github.com/wownero/wowlet/issues)
For live support, use Matrix or IRC. If you do not have an IRC client, join
[#wownero in your browser](https://webchat.oftc.net/?randomnick=1&channels=wownero).
If nobody replies immediately, stay in the room and give volunteers time to respond.
Please include your Wowlet version, operating system, the steps that caused the
problem, and any error message. Review **Help → Debug Info** before sharing it and
remove any personal information. Never share your wallet seed, private keys or
password, and do not upload your wallet files.
Report security vulnerabilities privately to
[wownero@wownero.org](mailto:wownero@wownero.org), rather than in a public issue or
chat. See the [Wowlet security policy](https://github.com/wownero/wowlet/blob/master/SECURITY.md).
Downloads and signed release hashes are available from
[Wowlet releases](https://github.com/wownero/wowlet/releases), also linked from
[wownero.org](https://wownero.org/).
+85
View File
@@ -0,0 +1,85 @@
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
ROOT = Path(__file__).resolve().parents[3]
class GenerateDocsTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.upstream = self.root / "external/feather-docs/content/_guides"
self.upstream.mkdir(parents=True)
self.output = self.root / "src/assets/docs"
self.output.mkdir(parents=True)
shutil.copytree(ROOT / "contrib/docs/overrides", self.root / "contrib/docs/overrides")
def write_upstream(self, name, body):
(self.upstream / name).write_text(
'---\ntitle: "Upstream guide"\nnav_title: "Upstream"\ncategory: "help"\n---\n'
+ body
)
def generate(self):
result = subprocess.run(
[sys.executable, "-B", str(ROOT / "contrib/docs/generate.py")],
cwd=self.root, capture_output=True, text=True,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def assert_wownero_contacts(self, document):
urls = [urlsplit(url) for url in re.findall(r"\]\(([^)]+)\)", document)]
self.assertTrue(any(
url.hostname == "matrix.to" and url.fragment == "/#wownero-gen:wowne.ro"
for url in urls
), "Matrix must lead to Wownero's published room")
self.assertTrue(any(
url.hostname == "webchat.oftc.net"
and "wownero" in parse_qs(url.query).get("channels", [])
for url in urls
), "Webchat must join Wownero's IRC channel")
self.assertTrue(any(
url.hostname == "github.com" and url.path == "/wownero/wowlet/issues"
for url in urls
), "Bug reports must go to the Wowlet tracker")
self.assertTrue(any(
url.scheme == "mailto" and url.path == "wownero@wownero.org"
for url in urls
), "Email must reach the published Wownero contact")
for url in urls:
self.assertNotIn("feather", url.geturl().lower(), "Support must not route to Feather")
def test_support_override_survives_regeneration(self):
self.write_upstream("report_an_issue.md", "Upstream support contacts")
self.write_upstream("accounts.md", "An upstream guide without an override")
(self.output / "stale.md").write_text("Obsolete generated guide")
for body in ("Upstream support contacts", "Changed upstream support contacts"):
self.write_upstream("report_an_issue.md", body)
self.generate()
support = (self.output / "report_an_issue.md").read_text()
self.assert_wownero_contacts(support)
self.assertNotIn(body, support)
self.assertRegex(support, r"(?m)^\[nav_title\]: # \(.+\)$")
self.assertRegex(support, r"(?m)^\[category\]: # \(.+\)$")
self.assertIn("An upstream guide without an override", (self.output / "accounts.md").read_text())
self.assertFalse((self.output / "stale.md").exists())
def test_readme_and_security_contacts_agree_with_bundled_help(self):
readme = (ROOT / "README.md").read_text()
support = readme.split("## Support\n", 1)[1].split("\n## ", 1)[0]
self.assert_wownero_contacts(support)
policy = (ROOT / "SECURITY.md").read_text()
self.assertIn("mailto:wownero@wownero.org", policy)
self.assertNotIn("featherwallet.org", policy)
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -34,13 +34,13 @@
<string>????</string> <string>????</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>feather</string> <string>wowlet</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>Feather</string> <string>Wowlet</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>org.monero-project.feather</string> <string>org.wownero.wowlet</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Camera is used to scan QR codes on demand.</string> <string>Camera is used to scan QR codes on demand.</string>
+22
View File
@@ -0,0 +1,22 @@
"""Refuse a build whose resolved source differs from its reviewed request."""
import os
import re
import subprocess
def check(expected, reported, checked_out):
if not re.fullmatch(r"[0-9a-f]{40}", expected or ""):
raise ValueError("expected_sha must identify the exact reviewed commit")
if reported != expected:
raise ValueError("provider run head differs from expected_sha")
if checked_out != expected:
raise ValueError("checked-out source differs from expected_sha")
if __name__ == "__main__":
head = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
try:
check(os.environ.get("EXPECTED_SHA"), os.environ.get("GITHUB_SHA"), head)
except ValueError as error:
raise SystemExit(str(error)) from None
print("Exact requested build source verified")
+73
View File
@@ -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()
+204
View File
@@ -0,0 +1,204 @@
"""Sign one private candidate without placing passwords in process arguments.
Apple SecurityTool reads one quoted operation from stdin in interactive mode:
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 = (
"APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "APPLE_SIGNING_IDENTITY",
"APPLE_API_ISSUER", "APPLE_API_KEY", "APPLE_API_KEY_P8_BASE64",
)
def argument(value):
if any(ord(char) < 32 or ord(char) == 127 for char in value):
raise ValueError("Keychain input contains an unsupported control character")
return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
def child_environment():
return {key: os.environ[key] for key in ("HOME", "PATH", "TMPDIR", "LANG", "DEVELOPER_DIR") if key in os.environ}
def security(operation, *values):
if operation not in {
"create-keychain", "set-keychain-settings", "unlock-keychain", "import",
"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"
if len(command.encode()) > 1024:
raise ValueError("Keychain input exceeds the bounded operation size")
result = subprocess.run(
["/usr/bin/security", "-i", "-q"], input=command,
text=True, capture_output=True, env=child_environment(), timeout=90,
)
if result.returncode:
# Never forward diagnostics or operation data. Report recognized causes.
causes = {
"MAC verification failed": "PKCS12 encoding or password verification failed",
"User interaction is not allowed": "keychain requires interactive access",
"already exists": "ephemeral keychain or imported identity already exists",
"could not be found": "requested keychain item was not found",
}
cause = next((cause for marker, cause in causes.items() if marker in result.stderr),
"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,
env=child_environment(), timeout=timeout)
if result.returncode:
causes = {
"no identity found": "expected signing identity was not found",
"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")
raise RuntimeError(f"{Path(arguments[0]).name}: {cause} (exit {result.returncode}); raw diagnostics withheld")
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:
stream.write(content)
def signing_inputs():
credentials = {name: os.environ.get(name, "") for name in FIELDS}
missing = [name for name, value in credentials.items() if not value]
if missing:
raise ValueError("Signing input is missing: " + ", ".join(missing))
if "Such Software" not in credentials["APPLE_SIGNING_IDENTITY"]:
raise ValueError("Developer ID identity does not name the expected publisher")
return credentials
def main():
credentials = signing_inputs()
app = Path("build/dist/Wowlet.app").resolve()
if not app.is_dir():
raise ValueError("Packaged Wowlet.app is missing")
old_umask = os.umask(0o077)
try:
with tempfile.TemporaryDirectory(prefix=".wowlet-signing-", dir=app.parent) as temporary:
workspace = Path(temporary)
keychain = workspace / "candidate.keychain-db"
certificate = workspace / "identity.p12"
notary_key = workspace / "notary.p8"
private_file(certificate, base64.b64decode(credentials["APPLE_CERTIFICATE"]))
private_file(notary_key, base64.b64decode(credentials["APPLE_API_KEY_P8_BASE64"]))
password = secrets.token_hex(32)
created = False
try:
security("create-keychain", "-p", password, str(keychain))
created = True
security("set-keychain-settings", "-lut", "3600", str(keychain))
security("unlock-keychain", "-p", password, str(keychain))
security("import", str(certificate), "-k", str(keychain), "-P",
credentials["APPLE_CERTIFICATE_PASSWORD"], "-T", "/usr/bin/codesign")
security("set-key-partition-list", "-S", "apple-tool:,apple:", "-s",
"-k", password, str(keychain))
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 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))
finally:
os.umask(old_umask)
if __name__ == "__main__":
try:
if sys.argv[1:] == ["--check-inputs"]:
signing_inputs()
print("Required signing inputs are present")
elif sys.argv[1:]:
raise ValueError("Use no arguments to sign or --check-inputs for the cheap input guard")
else:
main()
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error:
# These exceptions contain logical fields, native result classes or
# public file paths. Captured child output is never included.
raise SystemExit(str(error)) from None
@@ -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()
+20
View File
@@ -0,0 +1,20 @@
import importlib.util
from pathlib import Path
import unittest
spec = importlib.util.spec_from_file_location("dispatch", Path(__file__).parents[1] / "check_dispatch.py")
dispatch = importlib.util.module_from_spec(spec)
spec.loader.exec_module(dispatch)
class DispatchTests(unittest.TestCase):
def test_exact_source_is_required_before_building(self):
sha = "a" * 40
dispatch.check(sha, sha, sha)
for expected, reported, checked_out in (
(None, sha, sha), ("master", sha, sha),
(sha, "b" * 40, sha), (sha, sha, "b" * 40),
):
with self.subTest(expected=expected, reported=reported, checked_out=checked_out):
with self.assertRaises(ValueError):
dispatch.check(expected, reported, checked_out)
@@ -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())
+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)
+95
View File
@@ -0,0 +1,95 @@
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")
signing = importlib.util.module_from_spec(spec)
spec.loader.exec_module(signing)
class MacSigningTests(unittest.TestCase):
def test_password_uses_private_input_and_diagnostics_never_echo_it(self):
synthetic = 'synthetic "quoted" value \\ with spaces'
captured = []
def execute(arguments, **options):
captured.append((arguments, options))
return SimpleNamespace(returncode=1, stdout=synthetic,
stderr="MAC verification failed " + synthetic)
with patch.object(signing.subprocess, "run", execute):
with self.assertRaises(RuntimeError) as caught:
signing.security("import", "candidate.p12", "-P", synthetic)
self.assertNotIn(synthetic, str(caught.exception))
arguments, options = captured[0]
self.assertNotIn(synthetic, " ".join(arguments))
self.assertIn("quoted", options["input"])
self.assertTrue(options["capture_output"])
self.assertNotIn("APPLE_CERTIFICATE_PASSWORD", options["env"])
def test_missing_inputs_refuse_without_native_operations(self):
with patch.dict(signing.os.environ, {}, clear=True), patch.object(signing.subprocess, "run") as execute:
with self.assertRaises(ValueError):
signing.signing_inputs()
execute.assert_not_called()
def test_control_characters_and_unrelated_keychain_operations_refuse(self):
with patch.object(signing.subprocess, "run") as execute:
for value in ("line\nbreak", "nul\x00byte", "carriage\rreturn"):
with self.assertRaises(ValueError):
signing.security("unlock-keychain", "-p", value, "candidate.keychain")
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))
+12
View File
@@ -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"
}
}
+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()
+1 -1
Submodule monero updated: 8b05051b57...324867ad42
+13 -9
View File
@@ -148,7 +148,10 @@ add_executable(feather ${EXECUTABLE_FLAG} main.cpp
${ASSETS_TOR} ${ASSETS_TOR}
) )
# The CMake target keeps its upstream name so the target_* calls below stay put;
# only what we ship is renamed.
set_target_properties(feather PROPERTIES set_target_properties(feather PROPERTIES
OUTPUT_NAME wowlet
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
LINK_FLAGS_RELEASE -s LINK_FLAGS_RELEASE -s
) )
@@ -370,14 +373,14 @@ else()
) )
if (UNIX AND NOT APPLE) if (UNIX AND NOT APPLE)
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/feather.desktop DESTINATION ${CMAKE_INSTALL_PREFIX}/share/applications) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/wowlet.desktop DESTINATION ${CMAKE_INSTALL_PREFIX}/share/applications)
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/32x32.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/32x32/apps RENAME "feather.png") install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/32x32.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/32x32/apps RENAME "wowlet.png")
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/48x48.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/48x48/apps RENAME "feather.png") install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/48x48.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/48x48/apps RENAME "wowlet.png")
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/64x64.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/64x64/apps RENAME "feather.png") install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/64x64.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/64x64/apps RENAME "wowlet.png")
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/96x96.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/96x96/apps RENAME "feather.png") install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/96x96.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/96x96/apps RENAME "wowlet.png")
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/128x128.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/128x128/apps RENAME "feather.png") install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/128x128.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/128x128/apps RENAME "wowlet.png")
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/256x256.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/256x256/apps RENAME "feather.png") install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/256x256.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/256x256/apps RENAME "wowlet.png")
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/512x512.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/512x512/apps RENAME "feather.png") install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/512x512.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/512x512/apps RENAME "wowlet.png")
endif() endif()
endif() endif()
@@ -385,13 +388,14 @@ if(APPLE)
configure_file(${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist.in ${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist @ONLY) configure_file(${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist.in ${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist @ONLY)
set_target_properties(feather PROPERTIES set_target_properties(feather PROPERTIES
OUTPUT_NAME wowlet
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
MACOSX_BUNDLE TRUE MACOSX_BUNDLE TRUE
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist" MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist"
LINK_FLAGS_RELEASE -s LINK_FLAGS_RELEASE -s
) )
file(COPY "${CMAKE_SOURCE_DIR}/src/assets/images/appicons/appicon.icns" DESTINATION "${CMAKE_SOURCE_DIR}/installed/feather.app/Contents/Resources/" ) file(COPY "${CMAKE_SOURCE_DIR}/src/assets/images/appicons/appicon.icns" DESTINATION "${CMAKE_SOURCE_DIR}/installed/wowlet.app/Contents/Resources/" )
endif() endif()
qt_finalize_executable(feather) qt_finalize_executable(feather)
+1 -1
View File
@@ -606,7 +606,7 @@ Rectangle {
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
color: "#f2dcb4" color: "#f2dcb4"
font.pointSize: 9 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 " + "start the built-in node or point the wallet at a local daemon " +
"under Settings to mine." "under Settings to mine."
} }
+1 -1
View File
@@ -137,7 +137,7 @@
<item row="3" column="1"> <item row="3" column="1">
<widget class="QLabel" name="label_11"> <widget class="QLabel" name="label_11">
<property name="text"> <property name="text">
<string>codeberg.org/wownero/wowlet</string> <string>github.com/wownero/wowlet</string>
</property> </property>
<property name="textInteractionFlags"> <property name="textInteractionFlags">
<set>Qt::TextInteractionFlag::LinksAccessibleByMouse|Qt::TextInteractionFlag::TextSelectableByMouse</set> <set>Qt::TextInteractionFlag::LinksAccessibleByMouse|Qt::TextInteractionFlag::TextSelectableByMouse</set>