From 98c1e4418667765288045b4fe0664fc4dce4670d Mon Sep 17 00:00:00 2001 From: k9ert <117085+k9ert@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:00:04 +0200 Subject: [PATCH] fix: restore Intel macOS release compatibility --- .github/workflows/electron-smoketest.yml | 53 ++++++++- .github/workflows/release.yml | 101 +++++++++++++++--- docs/release-guide.md | 37 ++++++- pyinstaller/electron/downloadloc.js | 26 +++-- pyinstaller/electron/main.js | 21 ++-- pyinstaller/electron/package.json | 5 +- pyinstaller/electron/set-version.js | 29 ++++- pyinstaller/electron/src/config.js | 5 +- pyinstaller/electron/src/download.js | 20 +++- pyinstaller/electron/src/version-data.js | 47 ++++++++ pyinstaller/electron/test/downloadloc.test.js | 23 ++++ .../electron/test/version-data.test.js | 66 ++++++++++++ 12 files changed, 386 insertions(+), 47 deletions(-) create mode 100644 pyinstaller/electron/src/version-data.js create mode 100644 pyinstaller/electron/test/downloadloc.test.js create mode 100644 pyinstaller/electron/test/version-data.test.js diff --git a/.github/workflows/electron-smoketest.yml b/.github/workflows/electron-smoketest.yml index 780212749..370f2da57 100644 --- a/.github/workflows/electron-smoketest.yml +++ b/.github/workflows/electron-smoketest.yml @@ -4,11 +4,13 @@ on: pull_request: paths: - 'pyinstaller/electron/**' + - '.github/workflows/release.yml' - '.github/workflows/electron-smoketest.yml' push: branches: [master] paths: - 'pyinstaller/electron/**' + - '.github/workflows/release.yml' - '.github/workflows/electron-smoketest.yml' jobs: @@ -48,10 +50,57 @@ jobs: node --check "$f" done + - name: Test release manifest validation + working-directory: pyinstaller/electron + run: npm test + + - name: Boot electron, assert missing hash fails closed + working-directory: pyinstaller/electron + env: + NODE_ENV: development + SPECTER_DATA_FOLDER: ${{ runner.temp }}/specter_dev + run: | + mkdir -p "$RUNNER_TEMP/specter_dev" + echo '{"version":"v2.1.11-pre1","repository":"cryptoadvance/specter-desktop","sha256":{"x64":"","arm64":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}' \ + > "$RUNNER_TEMP/specter_dev/version-data.json" + + xvfb-run --auto-servernum --server-args='-screen 0 1280x960x24' \ + ./node_modules/.bin/electron . --no-sandbox > electron-invalid-manifest.log 2>&1 & + PID=$! + trap 'kill $PID 2>/dev/null || true; wait $PID 2>/dev/null || true' EXIT + EXPECTED="does not contain a valid specterd hash for x64" + + for _ in $(seq 1 30); do + if grep -qF "$EXPECTED" electron-invalid-manifest.log; then + if grep -qF 'Downloading from' electron-invalid-manifest.log; then + echo "invalid manifest attempted a download" + cat electron-invalid-manifest.log + exit 1 + fi + if grep -qF 'ReferenceError' electron-invalid-manifest.log; then + echo "invalid manifest raised an uncaught reference error" + cat electron-invalid-manifest.log + exit 1 + fi + echo "missing hash failed closed with an actionable error" + exit 0 + fi + if ! kill -0 $PID 2>/dev/null; then + echo "electron exited before reporting the invalid manifest" + cat electron-invalid-manifest.log + exit 1 + fi + sleep 1 + done + + echo "invalid manifest error not seen within 30s" + cat electron-invalid-manifest.log + exit 1 + - name: Seed dev data folder run: | mkdir -p "$RUNNER_TEMP/specter_dev" - echo '{"version":"","sha256":{"x64":"","arm64":""}}' \ + echo '{"version":"","repository":"cryptoadvance/specter-desktop","sha256":{"x64":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","arm64":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}' \ > "$RUNNER_TEMP/specter_dev/version-data.json" - name: Boot electron, assert splash reaches no-specterd branch @@ -71,7 +120,7 @@ jobs: # If main.js changes this message, update both sides together. EXPECTED="Specterd was not found and no version is configured" - for i in $(seq 1 30); do + for _ in $(seq 1 30); do if grep -qF "$EXPECTED" electron.log; then echo "smoketest passed (splash reached no-specterd branch)" exit 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 039b68d23..4bd922107 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -172,14 +172,14 @@ jobs: strategy: matrix: include: - # x64 builds require a paid macOS runner (macos-13 retired, macos-15-large is paid) - # Uncomment when org has paid plan: - # - runner: macos-15-large - # arch: x64 - # arch_label: osx_x64 + - runner: macos-15-intel + arch: x64 + arch_label: osx_x64 + file_arch: x86_64 - runner: macos-14 # Apple Silicon (free tier) arch: arm64 arch_label: osx_arm64 + file_arch: arm64 steps: - uses: actions/checkout@v4 with: @@ -217,6 +217,13 @@ jobs: pyinstaller specterd.spec cd .. + - name: Smoke test native specterd + run: | + FILE_OUTPUT=$(file pyinstaller/dist/specterd) + echo "$FILE_OUTPUT" + echo "$FILE_OUTPUT" | grep -q '${{ matrix.file_arch }}' + "pyinstaller/dist/specterd" --help + - name: Package release run: | VERSION=${GITHUB_REF#refs/tags/} @@ -441,6 +448,12 @@ jobs: name: specterd-macos-arm64 path: ./release-artifacts + - name: Download specterd macOS x64 artifact + uses: actions/download-artifact@v4 + with: + name: specterd-macos-x64 + path: ./release-artifacts + - name: Cache Electron binaries uses: actions/cache@v4 with: @@ -482,13 +495,27 @@ jobs: run: | VERSION=${GITHUB_REF#refs/tags/} source .buildenv/bin/activate - mkdir -p pyinstaller/dist + mkdir -p pyinstaller/dist/arm64 pyinstaller/dist/x64 cd release-artifacts - unzip specterd-${VERSION}-osx_arm64.zip -d ../pyinstaller/dist/ + unzip "specterd-${VERSION}-osx_arm64.zip" -d ../pyinstaller/dist/arm64 + unzip "specterd-${VERSION}-osx_x64.zip" -d ../pyinstaller/dist/x64 cd .. cd pyinstaller/electron npm ci - node ./set-version $VERSION ../dist/specterd + node ./set-version.js "$VERSION" ../dist/arm64/specterd arm64 "$GITHUB_REPOSITORY" + node ./set-version.js "$VERSION" ../dist/x64/specterd x64 "$GITHUB_REPOSITORY" + node -e " + const crypto = require('crypto'); + const fs = require('fs'); + const { isValidSha256 } = require('./src/version-data'); + const data = require('./version-data.json'); + for (const arch of ['arm64', 'x64']) { + if (!isValidSha256(data.sha256[arch])) throw new Error('Missing valid ' + arch + ' specterd hash'); + const binary = fs.readFileSync('../dist/' + arch + '/specterd'); + const actual = crypto.createHash('sha256').update(binary).digest('hex'); + if (data.sha256[arch] !== actual) throw new Error('Incorrect ' + arch + ' specterd hash'); + } + " cd ../.. - name: Patch electron-builder config for CI @@ -533,17 +560,44 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + - name: Smoke test universal Electron package + run: | + APP_PATH=pyinstaller/electron/dist/mac-universal/Specter.app + ELECTRON_BINARY="$APP_PATH/Contents/MacOS/Specter" + ASAR_PATH="$APP_PATH/Contents/Resources/app.asar" + + ARCHS=$(lipo -archs "$ELECTRON_BINARY") + echo "Electron architectures: $ARCHS" + echo "$ARCHS" | grep -qw x86_64 + echo "$ARCHS" | grep -qw arm64 + + ASAR_CONTENTS=$(pyinstaller/electron/node_modules/.bin/asar list "$ASAR_PATH") + echo "$ASAR_CONTENTS" | grep -qx '/src/splash.html' + echo "$ASAR_CONTENTS" | grep -qx '/version-data.json' + node -e " + const asar = require('./pyinstaller/electron/node_modules/@electron/asar'); + const crypto = require('crypto'); + const fs = require('fs'); + const data = JSON.parse(asar.extractFile(process.argv[1], 'version-data.json').toString()); + if (data.repository !== process.env.GITHUB_REPOSITORY) throw new Error('Incorrect packaged repository'); + for (const arch of ['arm64', 'x64']) { + const binary = fs.readFileSync('pyinstaller/dist/' + arch + '/specterd'); + const expected = crypto.createHash('sha256').update(binary).digest('hex'); + if (data.sha256[arch] !== expected) throw new Error('Incorrect packaged ' + arch + ' specterd hash'); + } + " "$ASAR_PATH" + - name: Package release run: | VERSION=${GITHUB_REF#refs/tags/} mkdir -p release - cp pyinstaller/electron/dist/Specter-*.dmg release/Specter-${VERSION}.dmg 2>/dev/null || true - # Fallback: if no DMG, package the .app - if [ ! -f release/Specter-${VERSION}.dmg ]; then - cd pyinstaller/electron/dist/mac-universal - zip -r ../../../../release/Specter-${VERSION}-macos.zip Specter.app - cd ../../../.. + DMG_FILES=(pyinstaller/electron/dist/Specter-*.dmg) + if [ "${#DMG_FILES[@]}" -ne 1 ] || [ ! -f "${DMG_FILES[0]}" ]; then + echo "Expected exactly one universal DMG, found: ${DMG_FILES[*]}" + exit 1 fi + cp "${DMG_FILES[0]}" "release/Specter-${VERSION}.dmg" + test -s "release/Specter-${VERSION}.dmg" - name: Upload artifact uses: actions/upload-artifact@v4 @@ -587,9 +641,21 @@ jobs: find artifacts/pip-package -type f -name "cryptoadvance_specter-*.tar.gz" \ -exec cp {} release-files/ \; 2>/dev/null || true + # A universal macOS release is incomplete without both native daemons + # and the DMG that carries their hashes. + for REQUIRED in \ + "specterd-${VERSION}-osx_x64.zip" \ + "specterd-${VERSION}-osx_arm64.zip" \ + "Specter-${VERSION}.dmg"; do + if [ ! -s "release-files/${REQUIRED}" ]; then + echo "Missing required macOS release artifact: ${REQUIRED}" + exit 1 + fi + done + # Generate SHA256SUMS cd release-files - sha256sum * > SHA256SUMS + sha256sum ./* > SHA256SUMS echo "=== SHA256SUMS ===" cat SHA256SUMS cd .. @@ -619,7 +685,7 @@ jobs: - name: Generate release body run: | VERSION=${GITHUB_REF#refs/tags/} - DL="https://github.com/cryptoadvance/specter-desktop/releases/download/${VERSION}" + DL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${VERSION}" cat > release-body.md << EOF *Please create a full backup* before migrating or any major internal changes like switching to an electrum based installation. You can easily create a backup in Settings --> Backup Specter (zip file). @@ -647,7 +713,7 @@ jobs: ## specterd Specterd is a command-line program that runs only the Specter server, behaving like a traditional web application. - Supported platforms: [Windows](${DL}/specterd-${VERSION}-win64.zip), [MacOS](${DL}/specterd-${VERSION}-osx_arm64.zip), [Linux (x86_64)](${DL}/specterd-${VERSION}-x86_64-linux-gnu.zip) + Supported platforms: [Windows](${DL}/specterd-${VERSION}-win64.zip), [macOS (Apple Silicon)](${DL}/specterd-${VERSION}-osx_arm64.zip), [macOS (Intel)](${DL}/specterd-${VERSION}-osx_x64.zip), [Linux (x86_64)](${DL}/specterd-${VERSION}-x86_64-linux-gnu.zip) ## PyPi Packages @@ -700,6 +766,7 @@ jobs: files: release-files/* body_path: release-body.md draft: true # Draft first, review before publishing + prerelease: ${{ contains(github.ref_name, '-pre') }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/release-guide.md b/docs/release-guide.md index 7f2675e34..ee9082779 100644 --- a/docs/release-guide.md +++ b/docs/release-guide.md @@ -18,7 +18,7 @@ git push upstream v1.13.1 That's it. The `Release` workflow on GitHub Actions takes it from here: - **`release-pip`** — builds the sdist/wheel and publishes to PyPI via trusted publishing. -- **`build-specterd-{linux,windows,macos}`** — builds the `specterd` binary on each platform (macOS arm64 on the free `macos-14` runner). +- **`build-specterd-{linux,windows,macos}`** — builds the `specterd` binary on each platform. macOS uses native `macos-14` (arm64) and `macos-15-intel` (x64) runners and smoke-tests each daemon before packaging. - **`build-electron-{linux,windows,macos}`** — builds the Electron apps using each platform's `specterd` artifact. Windows uses the public `electronuserland/builder:wine` image; macOS signs + notarizes if `APPLE_CERTIFICATE_BASE64` et al. are configured. - **`create-release`** — collects all artifacts, generates `SHA256SUMS`, signs it with the GPG key from the `GPG_PRIVATE_KEY` secret, generates a release body (with auto-generated "What's Changed" via `gh api .../generate-notes`), and creates a **draft** GitHub release. - **`trigger-docker`** — POSTs a repository-dispatch to `lncm/docker-specter-desktop` so Aaron's Docker build picks up the new tag (needs `AARON_TRIGGER` secret; skipped otherwise). @@ -58,3 +58,38 @@ The script installs the markdown prerequisite, regenerates the GH-page and downl If something fails mid-pipeline, re-running individual jobs is safe — they `actions/download-artifact` from prior jobs and overwrite existing release assets via `softprops/action-gh-release`. If the draft release already has assets from a stale run, delete the draft and re-run `create-release`. macOS builds are the most likely to fail due to Apple signing/notarization glitches. The workflow falls back to unsigned builds when `APPLE_CERTIFICATE_BASE64` is empty — useful for smoke-testing the pipeline on forks. + +## Validate a release candidate on a fork + +Do not create or push a tag until the candidate diff and local checks have been reviewed. Once local review is approved, use immutable pre-release tags on the selected validation fork; never move a pre-tag after GitHub Actions has tested it. The release manifest records `${{ github.repository }}`, so a fork-built app downloads its fork artifacts while the real upstream release continues to download from `cryptoadvance/specter-desktop`. + +```bash +git remote get-url origin +# Confirm that origin is the intended validation fork, not upstream. +git fetch upstream master +git merge-base --is-ancestor upstream/master HEAD +git status --short --branch +git diff --check upstream/master...HEAD +cd pyinstaller/electron +npm test +cd ../.. +CANDIDATE_TAG=v2.1.11-pre1 +if git rev-parse --verify "refs/tags/${CANDIDATE_TAG}"; then + echo "Tag already exists locally: ${CANDIDATE_TAG}" + exit 1 +fi +git tag "${CANDIDATE_TAG}" +git push origin "${CANDIDATE_TAG}" +``` + +The `-preN` tag creates a draft GitHub prerelease. PyPI publishing is disabled outside `cryptoadvance/specter-desktop`, and absent Apple/GPG secrets exercise the unsigned fork fallback; that does not validate upstream signing or notarization. + +Before accepting the candidate, record the workflow run URL and verify: + +- both macOS daemon jobs report the expected native architecture and pass `specterd --help`; +- `specterd-v2.1.11-preN-osx_x64.zip`, `specterd-v2.1.11-preN-osx_arm64.zip`, and `Specter-v2.1.11-preN.dmg` are present; +- `SHA256SUMS` contains those three artifacts; +- the published prerelease and embedded daemon repository point to the selected fork; +- the unsigned DMG launches, downloads, verifies, and relaunches on both a real Intel Mac and a real Apple Silicon Mac, including an upgrade with existing settings. + +If the candidate changes, create `v2.1.11-pre2` (then `pre3`, and so on) at the new reviewed commit. Upstream still requires a secret-enabled signing/notarization validation before publication. diff --git a/pyinstaller/electron/downloadloc.js b/pyinstaller/electron/downloadloc.js index 05d1390e4..c2e69dc19 100644 --- a/pyinstaller/electron/downloadloc.js +++ b/pyinstaller/electron/downloadloc.js @@ -1,14 +1,24 @@ -function orgName() { - // This can be changed in order to make download possible from other github orgs - return "cryptoadvance" +const DEFAULT_REPOSITORY = 'cryptoadvance/specter-desktop' +const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/ + +function repositoryName(repository = DEFAULT_REPOSITORY) { + if (!REPOSITORY_PATTERN.test(repository)) { + throw new Error(`Invalid specterd download repository: ${repository}`) + } + return repository } -function getDownloadLocation(version, platformname) { +function orgName() { + return DEFAULT_REPOSITORY.split('/')[0] +} + +function getDownloadLocation(version, platformname, arch = process.arch, repository = DEFAULT_REPOSITORY) { + const releaseRepository = repositoryName(repository) if (platformname != "osx") { - return `https://github.com/${orgName()}/specter-desktop/releases/download/${version}/specterd-${version}-${platformname}.zip` + return `https://github.com/${releaseRepository}/releases/download/${version}/specterd-${version}-${platformname}.zip` } - return `https://github.com/${orgName()}/specter-desktop/releases/download/${version}/specterd-${version}-${platformname}_${process.arch}.zip` + return `https://github.com/${releaseRepository}/releases/download/${version}/specterd-${version}-${platformname}_${arch}.zip` } function appName() { @@ -18,6 +28,6 @@ function appName() { module.exports = { getDownloadLocation, appName, - orgName + orgName, + repositoryName, } - diff --git a/pyinstaller/electron/main.js b/pyinstaller/electron/main.js index 95cc1d4f6..315450da3 100644 --- a/pyinstaller/electron/main.js +++ b/pyinstaller/electron/main.js @@ -12,7 +12,8 @@ const { downloadSpecterd, destroyProgressbar } = require('./src/download.js') const { startSpecterd, quitSpecterd } = require('./src/specterd.js') const { getFileHash, versionData, isDev, devFolder, isMac } = require('./src/helpers.js') const { getAppSettings } = require('./src/config.js') -const { showError, updatingLoaderMsg, initMainWindow, loadUrl, initTray } = require('./src/uiHelpers.js') +const { showError, updatingLoaderMsg, updateSpecterdStatus, initMainWindow, loadUrl, initTray } = require('./src/uiHelpers.js') +const { hashesMatch, missingHashMessage, synchronizeSpecterdSettings } = require('./src/version-data.js') // Quit again if there is no version-data in dev if (isDev && versionData === undefined) { @@ -136,24 +137,30 @@ app.whenReady().then(() => { setMainMenu() - loadUrl(`file://${__dirname}/splash.html`) + loadUrl(`file://${__dirname}/src/splash.html`) if (!fs.existsSync(specterdDirPath)) { logger.info('Creating specterd-binaries folder:' + specterdDirPath) fs.mkdirSync(specterdDirPath, { recursive: true }) } - if (!appSettings.versionInitialized || appSettings.versionInitialized != versionData.version) { + const settingsUpdate = synchronizeSpecterdSettings(appSettings, versionData, process.arch) + if (!settingsUpdate.hash) { + const errorMessage = missingHashMessage(versionData, process.arch) + logger.error(errorMessage) + updatingLoaderMsg(errorMessage) + updateSpecterdStatus('Failed to validate this release...') + return + } + + if (settingsUpdate.changed) { logger.info(`Updating ${appSettingsPath} : ${JSON.stringify(appSettings)}`) - appSettings.specterdVersion = versionData.version - appSettings.specterdHash = versionData.sha256[process.arch] - appSettings.versionInitialized = versionData.version fs.writeFileSync(appSettingsPath, JSON.stringify(appSettings)) } const specterdPath = specterdDirPath + '/' + appNameLower + 'd' if (fs.existsSync(specterdPath + (platformName == 'win64' ? '.exe' : ''))) { getFileHash(specterdPath + (platformName == 'win64' ? '.exe' : ''), function (specterdHash) { - if (appSettings.specterdHash.toLowerCase() == specterdHash || appSettings.specterdHash == '') { + if (hashesMatch(appSettings.specterdHash, specterdHash)) { startSpecterd(specterdPath) } else if (appSettings.specterdVersion != '') { updatingLoaderMsg('Specterd version could not be validated. Trying again to download the Specter binary ...') diff --git a/pyinstaller/electron/package.json b/pyinstaller/electron/package.json index 8a1af0c7a..5f6a72a30 100644 --- a/pyinstaller/electron/package.json +++ b/pyinstaller/electron/package.json @@ -7,7 +7,8 @@ "start": "export NODE_ENV=development && cp -R ../../src/cryptoadvance/specter/static/fonts ../../src/cryptoadvance/specter/static/output.css ../../src/cryptoadvance/specter/static/typography.css . && electron .", "pack": "electron-builder --dir", "dist": "electron-builder --publish=never", - "postinstall": "electron-builder install-app-deps" + "postinstall": "electron-builder install-app-deps", + "test": "node --test test/*.test.js" }, "repository": "https://github.com/cryptoadvance/specter-desktop", "keywords": [ @@ -69,4 +70,4 @@ "resolutions": { "**/request/form-data": "^2.5.4" } -} \ No newline at end of file +} diff --git a/pyinstaller/electron/set-version.js b/pyinstaller/electron/set-version.js index 8b9d9f8fa..c8bd9bd2c 100644 --- a/pyinstaller/electron/set-version.js +++ b/pyinstaller/electron/set-version.js @@ -1,11 +1,17 @@ const fs = require('fs'); const crypto = require('crypto'); const versionDataFile = './version-data.json'; +const { isValidSha256 } = require('./src/version-data'); async function setVersion() { const version = process.argv[2]; const file = process.argv[3]; const arch = process.argv[4] || process.arch; + const repository = process.argv[5] || process.env.GITHUB_REPOSITORY || 'cryptoadvance/specter-desktop'; + + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { + throw new Error(`Invalid release repository: ${repository}`); + } // Set version in package.json let packageJson = require('./package.json'); @@ -21,15 +27,20 @@ async function setVersion() { if (versionData.version != version) { console.log(`Version mismatch. Deleting ${versionDataFile} and creating anew.`); fs.unlinkSync(versionDataFile); // Delete the existing version-data.json file - versionData = createNewVersionData(version); // Create new version data object + versionData = createNewVersionData(version, repository); // Create new version data object } } catch (error) { console.log(`No ${versionDataFile} found. Creating anew.`); - versionData = createNewVersionData(version); + versionData = createNewVersionData(version, repository); } // Compute SHA256 hash of the provided file - versionData.sha256[arch] = await createHashFromFile(file); + const fileHash = await createHashFromFile(file); + if (!isValidSha256(fileHash)) { + throw new Error(`Could not generate a valid SHA-256 hash for ${file}`); + } + versionData.sha256[arch] = fileHash; + versionData.repository = repository; // Write new version data to file fs.writeFileSync(versionDataFile, JSON.stringify(versionData, undefined, 2)); console.log("version-data.js: ") @@ -41,10 +52,11 @@ async function setVersion() { } } -function createNewVersionData(version) { +function createNewVersionData(version, repository) { // Return a new version data object return { version, + repository, sha256: {} }; } @@ -57,4 +69,11 @@ const createHashFromFile = filePath => new Promise((resolve, reject) => { .on('error', reject); }); -setVersion().catch(console.error); \ No newline at end of file +if (require.main === module) { + setVersion().catch(error => { + console.error(error); + process.exitCode = 1; + }); +} + +module.exports = { createHashFromFile, createNewVersionData, setVersion }; diff --git a/pyinstaller/electron/src/config.js b/pyinstaller/electron/src/config.js index 3e87e3706..4470605d9 100644 --- a/pyinstaller/electron/src/config.js +++ b/pyinstaller/electron/src/config.js @@ -1,6 +1,7 @@ const os = require('os') const path = require('path') const fs = require('fs') +const { getSpecterdHash } = require('./version-data') const downloadloc = require('../downloadloc'); @@ -44,7 +45,7 @@ function getAppSettings() { tor: false, proxyURL: "socks5://127.0.0.1:9050", specterdVersion: (versionData && versionData.version !== undefined) ? versionData.version : 'unknown', - specterdHash: (versionData && versionData.sha256 !== undefined) ? versionData.sha256[process.arch] : 'unknown', + specterdHash: getSpecterdHash(versionData, process.arch), specterdCLIArgs: "", versionInitialized: false } @@ -107,4 +108,4 @@ module.exports = { getAppSettings, isDev: isDev, devFolder, -} \ No newline at end of file +} diff --git a/pyinstaller/electron/src/download.js b/pyinstaller/electron/src/download.js index 9838f2adc..16735a35a 100644 --- a/pyinstaller/electron/src/download.js +++ b/pyinstaller/electron/src/download.js @@ -3,12 +3,13 @@ const fs = require('fs') const { app, Menu } = require('electron') const extract = require('extract-zip') const { getDownloadLocation } = require('../downloadloc.js') -const { appName, appSettings, platformName, appNameLower, versionDataPath } = require('./config.js') +const { appName, appSettings, platformName, appNameLower, versionData, versionDataPath } = require('./config.js') const { isMac, getFileHash } = require('./helpers.js') const { logger } = require('./logging.js') const ProgressBar = require('electron-progressbar') const { updateSpecterdStatus, updatingLoaderMsg, createProgressBar } = require('./uiHelpers.js') const { startSpecterd } = require('./specterd.js') +const { hashesMatch, isValidSha256, missingHashMessage } = require('./version-data.js') let progressBar @@ -29,12 +30,25 @@ const dockMenuWithforceQuit = Menu.buildFromTemplate([ ]) function downloadSpecterd(specterdPath) { + if (!isValidSha256(appSettings.specterdHash)) { + const errorMessage = missingHashMessage({ version: appSettings.specterdVersion }, process.arch) + logger.error(errorMessage) + updatingLoaderMsg(errorMessage) + updateSpecterdStatus('Failed to validate this release...') + return + } + updatingLoaderMsg(`Starting download`) updateSpecterdStatus(`Downloading the ${appName} binary...`) // Some logging logger.info('Using version ' + appSettings.specterdVersion) logger.info('Using platformName ' + platformName) - download_location = getDownloadLocation(appSettings.specterdVersion, platformName) + download_location = getDownloadLocation( + appSettings.specterdVersion, + platformName, + process.arch, + versionData.repository + ) logger.info('Downloading from ' + download_location) download(download_location, specterdPath + '.zip', function (errored, errorMsg) { if (errored == true) { @@ -67,7 +81,7 @@ function downloadSpecterd(specterdPath) { fs.unlinkSync(specterdPath + '.zip') fs.rmdirSync(specterdPath + '-dir', { recursive: true }) getFileHash(specterdPath + (platformName == 'win64' ? '.exe' : ''), function (specterdHash) { - if (appSettings.specterdHash.toLowerCase() === specterdHash || appSettings.specterdHash == '') { + if (hashesMatch(appSettings.specterdHash, specterdHash)) { startSpecterd(specterdPath) } else { updatingLoaderMsg('Specterd version could not be validated.') diff --git a/pyinstaller/electron/src/version-data.js b/pyinstaller/electron/src/version-data.js new file mode 100644 index 000000000..59f0f06e7 --- /dev/null +++ b/pyinstaller/electron/src/version-data.js @@ -0,0 +1,47 @@ +const SHA256_PATTERN = /^[0-9a-f]{64}$/i + +function isValidSha256(value) { + return typeof value === 'string' && SHA256_PATTERN.test(value) +} + +function getSpecterdHash(versionData, arch) { + const hash = versionData && versionData.sha256 && versionData.sha256[arch] + return isValidSha256(hash) ? hash.toLowerCase() : null +} + +function hashesMatch(expectedHash, actualHash) { + return isValidSha256(expectedHash) && isValidSha256(actualHash) && expectedHash.toLowerCase() === actualHash.toLowerCase() +} + +function synchronizeSpecterdSettings(appSettings, versionData, arch) { + const hash = getSpecterdHash(versionData, arch) + if (!hash) { + return { changed: false, hash: null } + } + + const changed = + appSettings.specterdVersion !== versionData.version || + appSettings.versionInitialized !== versionData.version || + appSettings.specterdHash !== hash + + if (changed) { + appSettings.specterdVersion = versionData.version + appSettings.specterdHash = hash + appSettings.versionInitialized = versionData.version + } + + return { changed, hash } +} + +function missingHashMessage(versionData, arch) { + const version = versionData && versionData.version ? ` ${versionData.version}` : '' + return `Specter release${version} does not contain a valid specterd hash for ${arch}. Please install a complete release for this platform architecture.` +} + +module.exports = { + getSpecterdHash, + hashesMatch, + isValidSha256, + missingHashMessage, + synchronizeSpecterdSettings, +} diff --git a/pyinstaller/electron/test/downloadloc.test.js b/pyinstaller/electron/test/downloadloc.test.js new file mode 100644 index 000000000..6259dabd0 --- /dev/null +++ b/pyinstaller/electron/test/downloadloc.test.js @@ -0,0 +1,23 @@ +const assert = require('node:assert/strict') +const test = require('node:test') + +const { getDownloadLocation, repositoryName } = require('../downloadloc') + +test('builds repository-relative Intel and ARM macOS daemon URLs', () => { + assert.equal( + getDownloadLocation('v2.1.11-pre1', 'osx', 'x64', 'example/specter-desktop'), + 'https://github.com/example/specter-desktop/releases/download/v2.1.11-pre1/specterd-v2.1.11-pre1-osx_x64.zip' + ) + assert.equal( + getDownloadLocation('v2.1.11-pre1', 'osx', 'arm64', 'example/specter-desktop'), + 'https://github.com/example/specter-desktop/releases/download/v2.1.11-pre1/specterd-v2.1.11-pre1-osx_arm64.zip' + ) +}) + +test('keeps upstream as the compatibility default', () => { + assert.equal(repositoryName(), 'cryptoadvance/specter-desktop') +}) + +test('rejects malformed repository metadata', () => { + assert.throws(() => repositoryName('https://example.com/attacker/repo'), /Invalid specterd download repository/) +}) diff --git a/pyinstaller/electron/test/version-data.test.js b/pyinstaller/electron/test/version-data.test.js new file mode 100644 index 000000000..503f0d5cd --- /dev/null +++ b/pyinstaller/electron/test/version-data.test.js @@ -0,0 +1,66 @@ +const assert = require('node:assert/strict') +const test = require('node:test') + +const { + getSpecterdHash, + hashesMatch, + isValidSha256, + missingHashMessage, + synchronizeSpecterdSettings, +} = require('../src/version-data') + +const X64_HASH = 'a'.repeat(64) +const ARM64_HASH = 'B'.repeat(64) + +test('selects and normalizes the hash for each Electron architecture', () => { + const versionData = { version: 'v2.1.11-pre1', sha256: { x64: X64_HASH, arm64: ARM64_HASH } } + + assert.equal(getSpecterdHash(versionData, 'x64'), X64_HASH) + assert.equal(getSpecterdHash(versionData, 'arm64'), ARM64_HASH.toLowerCase()) +}) + +test('rejects missing, empty, malformed, and non-string architecture hashes', () => { + for (const hash of [undefined, null, '', 'unknown', 'a'.repeat(63), 42]) { + assert.equal(getSpecterdHash({ sha256: { x64: hash } }, 'x64'), null) + assert.equal(isValidSha256(hash), false) + } +}) + +test('hash comparison fails closed unless both values are valid SHA-256 strings', () => { + assert.equal(hashesMatch(X64_HASH.toUpperCase(), X64_HASH), true) + assert.equal(hashesMatch('', ''), false) + assert.equal(hashesMatch(undefined, X64_HASH), false) + assert.equal(hashesMatch(X64_HASH, 'not-a-hash'), false) +}) + +test('missing hash errors identify the release and host architecture', () => { + assert.match(missingHashMessage({ version: 'v2.1.11-pre1' }, 'x64'), /v2\.1\.11-pre1/) + assert.match(missingHashMessage({ version: 'v2.1.11-pre1' }, 'x64'), /x64/) +}) + +test('refreshes stale launcher settings without disturbing unrelated user settings', () => { + const settings = { + basicAuthUser: 'alice', + specterdHash: null, + specterdVersion: 'v2.1.10', + versionInitialized: 'v2.1.10', + } + const versionData = { version: 'v2.1.11-pre1', sha256: { x64: X64_HASH } } + + assert.deepEqual(synchronizeSpecterdSettings(settings, versionData, 'x64'), { changed: true, hash: X64_HASH }) + assert.equal(settings.basicAuthUser, 'alice') + assert.equal(settings.specterdHash, X64_HASH) + assert.equal(settings.specterdVersion, 'v2.1.11-pre1') + assert.equal(settings.versionInitialized, 'v2.1.11-pre1') +}) + +test('does not mutate settings when the release hash is missing', () => { + const settings = { specterdHash: 'stale', specterdVersion: 'v2.1.10' } + const original = { ...settings } + + assert.deepEqual(synchronizeSpecterdSettings(settings, { version: 'v2.1.11', sha256: {} }, 'x64'), { + changed: false, + hash: null, + }) + assert.deepEqual(settings, original) +})