From 309bcdcd16da3a052a3956be0ab80c4216fbfdb5 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 2 Nov 2026 00:20:07 +0100 Subject: [PATCH 1/3] ci(release): sign the standalone executables Sign them the way the desktop's builds are, by the owner's decision of 2026-11-02: a self-signed Authenticode certificate on Windows, from the SIGNTOOL_PFX_BASE64 and SIGNTOOL_PFX_PASSWORD secrets, and an ad-hoc signature with a stable identifier on macOS. Both happen before pack.sh, so the archive and SHA256SUMS hold the signed files and smoke.sh runs them, and both read the signatures back out of the archive. Windows signs on a tag only, since a branch of this repository can read the secrets too, and a tag without them stops before anything is built. macOS needs no secret, so every run signs and a pull request proves it. Neither system trusts these signatures, so the warnings of the first launch stay. The READMEs say so instead of "not signed yet", and section 6 of the plan records what the choice gives and what it does not, keeping what would remove the warnings. Signed-off-by: InstaZDLL --- .github/workflows/release.yml | 16 ++- .github/workflows/standalone.yml | 125 ++++++++++++++++++++++ CHANGELOG.md | 20 ++-- README.md | 2 +- docs/plans/standalone-download-plan.md | 133 ++++++++++++++++++------ packaging/standalone/README/macos.txt | 5 +- packaging/standalone/README/windows.txt | 5 +- 7 files changed, 258 insertions(+), 48 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 656526b2..965eac06 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,12 @@ name: Release # repository secrets; without them the build stops at its login rather than # publishing to one registry and announcing two. # +# The Windows executables in the archives are signed with the certificate in the +# `SIGNTOOL_PFX_BASE64` (the PFX, in base64) and `SIGNTOOL_PFX_PASSWORD` repository +# secrets; without them `standalone.yml` stops before it builds anything, and no +# release ships unsigned. The certificate is self-signed, so Windows still warns: +# §6 of docs/plans/standalone-download-plan.md says what it gives and what not. +# # The tag is the source of truth for the version. `Cargo.toml` is not read # here — a tag that disagrees with it would produce an image labelled one way # and reporting itself another, so the two are checked against each other @@ -116,14 +122,18 @@ jobs: run: | echo "image=${REGISTRY}/${REPOSITORY,,}" >> "$GITHUB_OUTPUT" - # After `verify`, like the image's build, and beside it. Unsigned for now: - # signing is #401, and the README in each archive says what to click past. + # After `verify`, like the image's build, and beside it. Named one by one rather + # than `secrets: inherit`, so that file holds these two and not the registry + # tokens. The README in each archive says what to click past. standalone: name: Standalone needs: verify uses: ./.github/workflows/standalone.yml with: tag: ${{ inputs.tag || github.ref_name }} + secrets: + SIGNTOOL_PFX_BASE64: ${{ secrets.SIGNTOOL_PFX_BASE64 }} + SIGNTOOL_PFX_PASSWORD: ${{ secrets.SIGNTOOL_PFX_PASSWORD }} # Built on native runners rather than under QEMU. A Rust release build # emulated for arm64 takes hours; GitHub's arm64 runners are free for public @@ -357,7 +367,7 @@ jobs: Linux (x86_64)|linux-x86_64.tar.gz Linux (arm64)|linux-arm64.tar.gz ARCHIVES - printf '\nThey are not signed yet, so Windows and macOS warn on the first launch: `README.txt` says what to click past. `SHA256SUMS` holds the checksums of them all, and `%s` the source of the FFmpeg they carry.\n\n' "$sources" + printf '\nThe Windows executables carry a self-signed certificate and the macOS ones an ad-hoc signature, which neither system trusts, so both warn on the first launch: `README.txt` says what to click past. `SHA256SUMS` holds the checksums of them all, and `%s` the source of the FFmpeg they carry.\n\n' "$sources" } >> notes.md printf '## Container image\n\n```bash\ndocker pull %s:%s\n```\n\nThe same image, digest for digest, is on Docker Hub:\n\n```bash\ndocker pull %s:%s\n```\n\nAlso published as `%s` in both, for `linux/amd64` and `linux/arm64` (a Raspberry Pi 4 or 5 on a 64-bit system).\n' \ "$IMAGE" "$VERSION" "${HUB_IMAGE#docker.io/}" "$VERSION" "$channel" >> notes.md diff --git a/.github/workflows/standalone.yml b/.github/workflows/standalone.yml index 9a1593fa..fae90600 100644 --- a/.github/workflows/standalone.yml +++ b/.github/workflows/standalone.yml @@ -18,6 +18,11 @@ name: Standalone # - The Windows server links the C runtime statically: a laptop that has never # installed the Visual C++ redistributable has no VCRUNTIME140.dll to load. # - macOS deploys to the same oldest versions as its FFmpeg. +# - The executables are signed before they are packed (§6 of the plan), so the +# archive and `SHA256SUMS` hold the signed files and the smoke test runs them: +# on Windows with the certificate in the secrets below, on a tag only, and on +# macOS ad hoc, on every run. Neither system trusts these signatures, so both +# still warn on the first launch. Linux has nothing to sign. on: workflow_call: @@ -26,6 +31,16 @@ on: description: "The tag being released" required: true type: string + # Not `required`, which holds a caller to naming a secret and says nothing of + # one that is empty. `signing-secrets` checks the values, and names what is + # missing. + secrets: + SIGNTOOL_PFX_BASE64: + description: "The Authenticode certificate, a PFX in base64" + required: false + SIGNTOOL_PFX_PASSWORD: + description: "The password of that PFX" + required: false pull_request: paths: - "packaging/standalone/**" @@ -41,8 +56,32 @@ concurrency: cancel-in-progress: ${{ !inputs.tag }} jobs: + # First, so a tag without its certificate stops before any build minute is + # spent, and no release can ship unsigned by accident. A run without a tag + # signs nothing on Windows, so it has nothing to check. + signing-secrets: + name: Signing secrets + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Refuse a tag without its certificate + if: inputs.tag + env: + PFX_BASE64: ${{ secrets.SIGNTOOL_PFX_BASE64 }} + PFX_PASSWORD: ${{ secrets.SIGNTOOL_PFX_PASSWORD }} + run: | + if [ -z "$PFX_BASE64" ]; then + echo "::error::SIGNTOOL_PFX_BASE64 secret is required to publish a release" + exit 1 + fi + if [ -z "$PFX_PASSWORD" ]; then + echo "::error::SIGNTOOL_PFX_PASSWORD secret is required alongside SIGNTOOL_PFX_BASE64" + exit 1 + fi + ffmpeg: name: FFmpeg + needs: signing-secrets uses: ./.github/workflows/ffmpeg.yml with: tag: ${{ inputs.tag }} @@ -52,6 +91,7 @@ jobs: # catches. webapp: name: Web client + needs: signing-secrets runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -151,6 +191,46 @@ jobs: RUSTFLAGS: -C target-feature=+crt-static run: cargo build --locked --release + # On a tag only. A branch of this repository can read the secrets too, and + # the certificate is there to sign what is released, not what a branch + # built. The PFX lives in the runner's temporary folder, which nothing packs, + # for as long as signtool runs. + - name: Sign (Windows) + if: runner.os == 'Windows' && inputs.tag + shell: pwsh + env: + PFX_BASE64: ${{ secrets.SIGNTOOL_PFX_BASE64 }} + PFX_PASSWORD: ${{ secrets.SIGNTOOL_PFX_PASSWORD }} + run: | + Write-Host "::add-mask::$env:PFX_PASSWORD" + $signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" | + Sort-Object -Property @{ Expression = { try { [Version]$_.Directory.Parent.Name } catch { [Version]"0.0.0.0" } } } -Descending | + Select-Object -First 1 + if (-not $signtool) { throw "signtool.exe not found in the Windows Kits" } + $pfx = Join-Path $env:RUNNER_TEMP "signing.pfx" + try { + [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:PFX_BASE64)) + & $signtool.FullName sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /f $pfx /p $env:PFX_PASSWORD ` + target\release\waveflow-server.exe ffmpeg-out\ffmpeg.exe ffmpeg-out\ffprobe.exe + if ($LASTEXITCODE -ne 0) { throw "signtool exited with $LASTEXITCODE" } + } finally { + Remove-Item -LiteralPath $pfx -Force -ErrorAction SilentlyContinue + } + + # Needs no secret, so every run does it and a pull request proves it. `-` is + # ad hoc; a Developer ID would be this identity, with its certificate + # imported first and the notarization of §6 after the archive. The linker + # signs an arm64 binary on its own, under a generated identifier, and an + # x86_64 one not at all: `--force` and `--identifier` make the two alike. + - name: Sign (macOS) + if: runner.os == 'macOS' + env: + SIGNING_IDENTITY: "-" + run: | + codesign --force --sign "$SIGNING_IDENTITY" --identifier app.waveflow.server target/release/waveflow-server + codesign --force --sign "$SIGNING_IDENTITY" --identifier app.waveflow.server.ffmpeg ffmpeg-out/ffmpeg + codesign --force --sign "$SIGNING_IDENTITY" --identifier app.waveflow.server.ffprobe ffmpeg-out/ffprobe + # The tag when there is one. Otherwise the commit, which for a pull request # is its own head rather than the merge commit GitHub tests, which exists # nowhere once the run is over. The version is `verify`'s in a release, @@ -174,6 +254,51 @@ jobs: shell: bash run: sh packaging/standalone/smoke.sh dist/waveflow-server-* + # Read back from the archive, which is what ships. The certificate is + # self-signed, so its chain ends at a root Windows does not trust and the + # status is `UnknownError`, never `Valid`: what this holds to is that each + # file's hash matches (`HashMismatch` otherwise), that it has a signer and a + # timestamp, and that the three share one signer. Its thumbprint is public, + # and goes in the log for anyone comparing a download against it. + - name: Check the signatures (Windows) + if: runner.os == 'Windows' && inputs.tag + shell: pwsh + run: | + $extracted = Join-Path $env:RUNNER_TEMP "signatures" + Expand-Archive -Path dist\waveflow-server-*.zip -DestinationPath $extracted + $signers = foreach ($tool in "waveflow-server.exe", "ffmpeg.exe", "ffprobe.exe") { + $file = Get-ChildItem -Path $extracted -Recurse -Filter $tool + $signature = Get-AuthenticodeSignature -LiteralPath $file.FullName + $signer = $signature.SignerCertificate + Write-Host "${tool}: $($signature.Status), $($signer.Subject), $($signer.Thumbprint)" + if ($signature.Status.ToString() -notin "Valid", "UnknownError") { throw "${tool}: $($signature.StatusMessage)" } + if (-not $signer) { throw "${tool} has no signer" } + if (-not $signature.TimeStamperCertificate) { throw "${tool} has no timestamp" } + $signer.Thumbprint + } + if (@($signers | Select-Object -Unique).Count -ne 1) { throw "the executables have different signers: $signers" } + + # `--verify` alone would pass on the linker's signature of an arm64 binary, + # had the signing above done nothing. The identifier is what only it wrote. + - name: Check the signatures (macOS) + if: runner.os == 'macOS' + run: | + extracted="$RUNNER_TEMP/signatures" + unzip -q dist/waveflow-server-*.zip -d "$extracted" + for tool in waveflow-server ffmpeg ffprobe; do + case "$tool" in + waveflow-server) wanted=app.waveflow.server ;; + *) wanted="app.waveflow.server.$tool" ;; + esac + file=$(echo "$extracted"/*/"$tool") + codesign --verify --strict --verbose=2 "$file" + identifier=$(codesign --display --verbose=2 "$file" 2>&1 | sed -n 's/^Identifier=//p') + if [ "$identifier" != "$wanted" ]; then + echo "::error::$tool is signed as '$identifier', not $wanted" + exit 1 + fi + done + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: standalone-archive-${{ matrix.target }} diff --git a/CHANGELOG.md b/CHANGELOG.md index ca9c1540..ed3e45fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,13 +40,19 @@ out first, whether or not it breaks a compile. `-macos-arm64.zip`, `-macos-x86_64.zip`, `-linux-x86_64.tar.gz` and `-linux-arm64.tar.gz`, each one folder holding the server, its own FFmpeg, `laptop.env`, the licences and a `README.txt`, with - `ffmpeg--sources.tar.gz` and a `SHA256SUMS` over all of them. They are - not signed yet, so Windows and macOS warn on the first launch, and the - `README.txt` says what to click past. The Linux server is static against musl - and runs on any distribution; the Windows one needs no Visual C++ - redistributable. Each archive is started and made to play a file on its own - platform before the release attaches it, and the image's tags wait for them - too, so a tag whose archives fail publishes neither. + `ffmpeg--sources.tar.gz` and a `SHA256SUMS` over all of them. Windows + and macOS warn on the first launch, and the `README.txt` says what to click + past. The Linux server is static against musl and runs on any distribution; + the Windows one needs no Visual C++ redistributable. Each archive is started + and made to play a file on its own platform before the release attaches it, + and the image's tags wait for them too, so a tag whose archives fail publishes + neither. +- **The standalone executables are signed** (#401), as the desktop app's builds + are: with a self-signed Authenticode certificate on Windows, ad hoc on macOS. + A file changed after signing no longer matches its signature, but neither + system trusts these signatures, so the warnings of the first launch stay. A + release needs the `SIGNTOOL_PFX_BASE64` and `SIGNTOOL_PFX_PASSWORD` secrets and + stops before building without them. - **The transcoding state in the web client's administration**: whether the server can transcode (without it, listeners get the original files only), the transcodes running against the global limit, the per-account limit and diff --git a/README.md b/README.md index eee16941..2ec372c3 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ From 2.1.0, each [release](https://github.com/InstaZDLL/waveflow-server/releases | Linux (x86_64) | `waveflow-server--linux-x86_64.tar.gz` | | Linux (arm64) | `waveflow-server--linux-arm64.tar.gz` | -Each holds one folder: the server, its own FFmpeg, a `laptop.env` of settings, the licences, and a `README.txt` that walks through the first start. The archives are not signed yet, so Windows and macOS warn on the first launch; the `README.txt` says what to click past. `SHA256SUMS`, on the same release, holds their checksums, and `ffmpeg--sources.tar.gz` the source of the FFmpeg they carry. The Linux archives are static and run on any distribution. +Each holds one folder: the server, its own FFmpeg, a `laptop.env` of settings, the licences, and a `README.txt` that walks through the first start. The Windows executables carry a self-signed certificate and the macOS ones an ad-hoc signature, which neither system trusts, so both warn on the first launch; the `README.txt` says what to click past. `SHA256SUMS`, on the same release, holds their checksums, and `ffmpeg--sources.tar.gz` the source of the FFmpeg they carry. The Linux archives are static and run on any distribution. The rest of this section is for a server that stays on: the data lives in a folder of your choosing, and the commands assume a terminal. diff --git a/docs/plans/standalone-download-plan.md b/docs/plans/standalone-download-plan.md index 1e5439b2..d007b7c8 100644 --- a/docs/plans/standalone-download-plan.md +++ b/docs/plans/standalone-download-plan.md @@ -3,7 +3,7 @@ > Plan of 2026-10-29, written against `main` at `4c32b51`, while 2.0.0 is > prepared as a container image for `linux/amd64` and `linux/arm64`. > -> **Status as of 2026-11-01:** steps 1 to 5 of §8 are built. FFmpeg is +> **Status as of 2026-11-02:** steps 1 to 6 of §8 are built. FFmpeg is > found beside the executable (§4), and laptop mode exists in the server: > `WAVEFLOW_LAPTOP`, read from the `laptop.env` beside the executable, moves > the data folder and the cache size, checks the port before the boot, @@ -17,7 +17,9 @@ > (#399, §7); the image keeps Debian's FFmpeg, now trixie's 7.1 (#395). No tag > has produced the archives yet: the check that each one, extracted on its > platform, starts in laptop mode and plays a file waits for the first release -> candidate. Not built yet: signing (step 6, #401). +> candidate. The executables are signed as the desktop's are, self-signed on +> Windows and ad hoc on macOS (#401, §6), and both systems still warn: the +> project chose that on 2026-11-02, and §6 keeps what would remove the warnings. The question this plan answers: can someone who has never opened a terminal download one archive, double-click what is inside, and end up in the web @@ -27,9 +29,9 @@ Almost. The binary is already self-contained — SQLite is compiled in, the web client is embedded by `rust_embed`, there is no service to install beside it. Three things stand in the way, and this plan takes them in turn: FFmpeg has to be on `PATH` (§2 to §4), every setting is an environment variable with -server-room defaults (§5), and an unsigned executable downloaded from the -internet is something both Windows and macOS refuse to open without a fight -(§6). +server-room defaults (§5), and an executable downloaded from the internet, with +no signature they trust, is something both Windows and macOS refuse to open +without a fight (§6). ## 1. What stays as it is @@ -64,8 +66,8 @@ internet is something both Windows and macOS refuse to open without a fight says where the corresponding source of each is (§3). - **`README.txt`** is the page a non-technical user reads: what to double-click, what the first screen asks for, where the music library is set, how to stop - the server, and what to click past on first launch while the archives are - unsigned (§6). + the server, and what to click past on first launch, for as long as neither + system trusts the signatures (§6). - **Linux is `tar.gz`, not `zip`:** a zip loses the executable bit on most extractors. An AppImage is the Linux equivalent of a double-clickable file and is noted in §8 as a later option, not part of the first archives. @@ -325,35 +327,94 @@ data folder, as documented in the operations guide. ## 6. Signing -Until the archives are signed, the first launch shows a warning on Windows and -macOS, and `README.txt` says what to click. That is acceptable for a beta; not -for a download offered to people who have never seen the warning. - -### Windows: SmartScreen +**Decision of 2026-11-02: the archives are signed the way the desktop app's +builds are**, with a self-signed Authenticode certificate on Windows and an +ad-hoc signature on macOS. *Built (#401).* It costs nothing and depends on +nobody outside the project, and **it removes no warning**: the first launch +still warns on Windows and macOS, and `README.txt` says what to click. This +replaces this section's first decision, SignPath and a Developer ID. That +reasoning stays below, because it is what would remove the warnings if the +project wants that later. + +### What the project chose + +- **Windows.** `signtool` signs `waveflow-server.exe`, `ffmpeg.exe` and + `ffprobe.exe`, timestamped, from the `SIGNTOOL_PFX_BASE64` (the PFX, in + base64) and `SIGNTOOL_PFX_PASSWORD` repository secrets, the names the desktop + uses. An Actions secret belongs to one repository, so both are set on this one + too. A tag without them stops before any build. A pull request or a manual run + signs nothing on Windows, even when the secrets are readable, so the + certificate only ever signs what is released. +- **macOS.** `codesign --force --sign -` with a stable identifier + (`app.waveflow.server`, and `.ffmpeg` and `.ffprobe` for the tools) on all + three executables of both targets, on every run, since it needs no secret. + Moving to a Developer ID changes that identity and adds the steps described + below. +- **Linux.** Nothing. + +Both sign before `pack.sh`, so the archive and `SHA256SUMS` hold the signed +files and `smoke.sh` runs them, and both read the signatures back out of the +archive afterwards. + +**What it gives.** A file changed after signing no longer matches its signature: +`Get-AuthenticodeSignature` answers `HashMismatch` and `codesign --verify` fails. +On Windows the three files name one publisher's certificate, for as long as the +project keeps that certificate, and each run's log prints its thumbprint, so +someone who wants to can compare a download against it. The timestamp keeps the +signature readable once the certificate has expired. On macOS the executables +have a stable identifier rather than the one the linker generates on arm64, and +x86_64, which had no signature at all, has one. + +**What it does not give.** Trust, on either system: + +- The certificate's chain ends at a root outside Microsoft's Trusted Root + Program, so Windows calls the publisher unknown, `Get-AuthenticodeSignature` + answers `UnknownError` rather than `Valid`, and SmartScreen goes on warning. + More downloads will not change that. Smart App Control can block the executable + outright, as the desktop has seen (`docs/RELEASING.md` there, + InstaZDLL/WaveFlow#610). +- A certificate's name proves nothing: anyone can make a self-signed one under + the same name. Only the thumbprint tells them apart. +- An ad-hoc signature names nobody, and anyone can re-sign a changed file ad hoc. + It is a seal, not an identity, and it does not satisfy Gatekeeper, which + refuses the quarantined file as it refuses an unsigned one. +- Nothing checks any of this for the user. A signature shows tampering only to + someone who looks. + +### Windows: what would remove SmartScreen's warning + +Not done; this is what the first decision rested on. - An unsigned `.exe` from the internet gets "Windows protected your PC", with the run button behind "More info". Signing with an Authenticode certificate - removes "unknown publisher"; SmartScreen's warning itself disappears only as - the certificate builds reputation — an EV certificate no longer skips that. + from a CA in the Trusted Root Program removes "unknown publisher"; SmartScreen's + warning itself disappears only as the certificate builds reputation — an EV + certificate no longer skips that. A self-signed certificate does neither. + With a CA's certificate Smart App Control lets the executable through as well, + by the desktop's account of it. - **Azure Artifact Signing** (formerly Trusted Signing) is the cheapest managed option, but Microsoft's documentation limits it to organisations in the US, Canada, the EU and the UK and to *individual* developers in the US and Canada; an individual maintainer in the EU does not qualify today. -- **Decision: apply to the [SignPath Foundation](https://signpath.org/)**, which - signs open-source releases for free from a CI pipeline, with the key in its - HSM. Its conditions — an OSI licence, a public build from the tagged source, - no proprietary components — the project already meets. A commercial OV - certificate is the fallback. +- **The first decision, set aside on 2026-11-02: apply to the + [SignPath Foundation](https://signpath.org/)**, which signs open-source + releases for free from a CI pipeline, with the key in its HSM. Its conditions — + an OSI licence, a public build from the tagged source, no proprietary + components — the project already meets. A commercial OV certificate is the + fallback. An OV certificate would take the place of the one behind the two + secrets; SignPath, of the signing step. - All three executables are signed: `ffmpeg.exe` and `ffprobe.exe` are executables the user runs indirectly, and an unsigned one would be flagged by the same reputation system on its own. -### macOS: Gatekeeper and notarization +### macOS: what would satisfy Gatekeeper + +Not done either. -- A quarantined unsigned binary is refused. Since macOS 15 the Control-click - bypass is gone: the user must open System Settings › Privacy & Security and - choose "Open Anyway". That is the page `README.txt` explains while the - archives are unsigned. +- A quarantined binary without a Developer ID is refused, ad-hoc signature or + not. Since macOS 15 the Control-click bypass is gone: the user must open System + Settings › Privacy & Security and choose "Open Anyway". That is the page + `README.txt` explains, beside the `xattr` line. - Signing needs an **Apple Developer Program** membership (99 USD a year) and a *Developer ID Application* certificate. Every Mach-O in the archive is signed with the hardened runtime (`codesign --options runtime --timestamp`), @@ -378,7 +439,8 @@ instead (below), which also covers the other platforms for anyone who checks. `verify`, beside the image's build; that file calls `ffmpeg.yml`. Nothing in either publishes, so a pull request that touches `packaging/standalone/` or a manual dispatch runs the same jobs and stops at a workflow artifact, which is -how they are exercised without a tag. In order: +how they are exercised without a tag. First, on a tag, `signing-secrets` refuses +one that lacks the certificate of §6, before anything is built. Then, in order: 1. **`ffmpeg`** — `ffmpeg.yml` as it stands: one native runner per target builds the minimal FFmpeg, runs the `media`, `quality` and `canvas` targets @@ -391,13 +453,15 @@ how they are exercised without a tag. In order: what this page first proposed: what a release ships is compiled from its tag, and its build costs minutes, on a day that is rare. 2. **`webapp`** — the client, once, for all five. -3. **`archive`** — one native runner per target builds the server, packs the - archive of §2 with `packaging/standalone/pack.sh`, and starts it with +3. **`archive`** — one native runner per target builds the server, signs the + three executables (§6), packs the archive of §2 with + `packaging/standalone/pack.sh`, and starts it with `packaging/standalone/smoke.sh`: the files of §2, nothing loaded that the system lacks, laptop mode from the `laptop.env` in the archive, and an empty `PATH`, so the tools can only be the ones beside it, then a FLAC file turned - into an MP3 through the Subsonic façade. macOS builds on a runner of each - architecture rather than cross-compiling, as `ffmpeg.yml` does. + into an MP3 through the Subsonic façade. It then reads the signatures back out + of the archive. macOS builds on a runner of each architecture rather than + cross-compiling, as `ffmpeg.yml` does. Two builds differ from the image's, because a laptop is not a container whose libraries we choose. **On Linux the server is static against musl**, @@ -449,7 +513,10 @@ Each line is one pull request. with `README.txt` saying what to click past; the header rewritten. *Done* (#399), §7 says how; the first release candidate is what checks each archive on a real desktop. -6. **Signing**: SignPath for Windows once accepted, Developer ID and - notarization for macOS once the membership exists. -7. Later, as asked for: a macOS `.app` in a stapled `.dmg`, an AppImage, a tray - icon on Windows and macOS, and an update notice in the web client. +6. **Signing**: a self-signed certificate on Windows, ad hoc on macOS, as the + desktop does. *Done* (#401). It leaves both warnings in place, by the decision + of 2026-11-02 in §6, and a tag is what first signs with the real certificate. +7. Later, as asked for: what would remove those warnings (a CA certificate or + SignPath for Windows, a Developer ID and notarization for macOS), a macOS + `.app` in a stapled `.dmg`, an AppImage, a tray icon on Windows and macOS, and + an update notice in the web client. diff --git a/packaging/standalone/README/macos.txt b/packaging/standalone/README/macos.txt index 1c41e046..fc7ee016 100644 --- a/packaging/standalone/README/macos.txt +++ b/packaging/standalone/README/macos.txt @@ -9,8 +9,9 @@ To start it 1. Double-click waveflow-server. The first time, macOS refuses to open it, because this download is not - signed yet. It refuses each of the three programs in this folder in turn: - waveflow-server, ffmpeg and ffprobe. There are two ways past that. + signed with a certificate from Apple. It refuses each of the three programs + in this folder in turn: waveflow-server, ffmpeg and ffprobe. There are two + ways past that. The short way, once for the whole folder: open Terminal, type the line below followed by a space, drag this folder onto the Terminal window, and diff --git a/packaging/standalone/README/windows.txt b/packaging/standalone/README/windows.txt index e8414a3d..b9241af0 100644 --- a/packaging/standalone/README/windows.txt +++ b/packaging/standalone/README/windows.txt @@ -12,8 +12,9 @@ To start it 2. Double-click waveflow-server.exe. - The first time, Windows may say "Windows protected your PC", because this - download is not signed yet. Click "More info", then "Run anyway". + The first time, Windows may say "Windows protected your PC" and call the + publisher unknown: this download is signed, but with a certificate of our + own, which Windows does not know. Click "More info", then "Run anyway". A window opens, and your web browser opens on the server's page. From e35ff12f1faeafe8487480043e061e1adb5a8f85 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 2 Nov 2026 00:24:40 +0100 Subject: [PATCH 2/3] ci(release): sign on windows in a job that builds nothing Scoping the secrets to the signing step kept them out of cargo build's environment only: that build runs the build scripts and procedural macros of every dependency, and a process one of them leaves on the runner outlives its step, there to read the PFX or the password on signtool's command line. Build the Windows server in its own job and sign on a fresh runner that checks out nothing and runs nothing it was handed. Windows' archive job then packs and starts what that job uploaded, without the secrets. Signed-off-by: InstaZDLL --- .github/workflows/standalone.yml | 136 ++++++++++++++++++------- docs/plans/standalone-download-plan.md | 18 ++-- 2 files changed, 114 insertions(+), 40 deletions(-) diff --git a/.github/workflows/standalone.yml b/.github/workflows/standalone.yml index fae90600..27e88ea5 100644 --- a/.github/workflows/standalone.yml +++ b/.github/workflows/standalone.yml @@ -23,6 +23,8 @@ name: Standalone # on Windows with the certificate in the secrets below, on a tag only, and on # macOS ad hoc, on every run. Neither system trusts these signatures, so both # still warn on the first launch. Linux has nothing to sign. +# - The Windows server is built in a job of its own and signed in another, so the +# certificate never reaches a runner where a dependency's build script has run. on: workflow_call: @@ -118,9 +120,99 @@ jobs: retention-days: 7 if-no-files-found: error + windows-server: + name: Server (windows-x86_64) + needs: webapp + runs-on: windows-2025 + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || '' }} + persist-credentials: false + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: standalone-webapp + path: webapp/dist + + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # stable + with: + toolchain: "1.98" + + - name: Build + env: + RUSTFLAGS: -C target-feature=+crt-static + run: cargo build --locked --release + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: standalone-windows-server + path: target/release/waveflow-server.exe + retention-days: 7 + if-no-files-found: error + + # Holds the certificate, so it checks out nothing and runs nothing it was + # handed. In the job that builds, the secrets would be out of reach of the steps + # before this one only on paper: `cargo build` runs the build scripts and the + # procedural macros of every dependency, and a process one of them leaves behind + # outlives its step, there to read the PFX, or the password on signtool's command + # line. On a tag only, too: a branch of this repository can read the secrets, and + # the certificate is there to sign what is released, not what a branch built. + windows-sign: + name: Sign (windows-x86_64) + needs: [ffmpeg, windows-server] + runs-on: windows-2025 + timeout-minutes: 15 + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: standalone-windows-server + path: target/release + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ffmpeg-${{ needs.ffmpeg.outputs.version }}-windows-x86_64 + path: ffmpeg-out + + # The PFX lives in the runner's temporary folder, which nothing uploads, for + # as long as signtool runs. + - name: Sign + if: inputs.tag + shell: pwsh + env: + PFX_BASE64: ${{ secrets.SIGNTOOL_PFX_BASE64 }} + PFX_PASSWORD: ${{ secrets.SIGNTOOL_PFX_PASSWORD }} + run: | + Write-Host "::add-mask::$env:PFX_PASSWORD" + $signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" | + Sort-Object -Property @{ Expression = { try { [Version]$_.Directory.Parent.Name } catch { [Version]"0.0.0.0" } } } -Descending | + Select-Object -First 1 + if (-not $signtool) { throw "signtool.exe not found in the Windows Kits" } + $pfx = Join-Path $env:RUNNER_TEMP "signing.pfx" + try { + [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:PFX_BASE64)) + & $signtool.FullName sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /f $pfx /p $env:PFX_PASSWORD ` + target\release\waveflow-server.exe ffmpeg-out\ffmpeg.exe ffmpeg-out\ffprobe.exe + if ($LASTEXITCODE -ne 0) { throw "signtool exited with $LASTEXITCODE" } + } finally { + Remove-Item -LiteralPath $pfx -Force -ErrorAction SilentlyContinue + } + + # Two paths, so the artifact keeps them, and `archive` finds both where the + # other targets have theirs. + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: standalone-windows-executables + path: | + target/release/waveflow-server.exe + ffmpeg-out/ + retention-days: 7 + if-no-files-found: error + archive: name: Archive (${{ matrix.target }}) - needs: [ffmpeg, webapp] + needs: [ffmpeg, webapp, windows-sign] runs-on: ${{ matrix.runner }} timeout-minutes: 90 strategy: @@ -146,15 +238,23 @@ jobs: persist-credentials: false - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + if: runner.os != 'Windows' with: name: standalone-webapp path: webapp/dist - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + if: runner.os != 'Windows' with: name: ffmpeg-${{ needs.ffmpeg.outputs.version }}-${{ matrix.target }} path: ffmpeg-out + # Built and signed already, by the two jobs above. + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + if: runner.os == 'Windows' + with: + name: standalone-windows-executables + # The toolchain is the image's, and its version the Dockerfile's. The three # targets that exercise FFmpeg ran in `ffmpeg.yml` against a glibc build; # these are what says the server works on musl too. An artifact keeps no @@ -175,7 +275,7 @@ jobs: ' - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # stable - if: runner.os != 'Linux' + if: runner.os == 'macOS' with: toolchain: "1.98" @@ -185,38 +285,6 @@ jobs: MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macos-deployment-target }} run: cargo build --locked --release - - name: Build (Windows) - if: runner.os == 'Windows' - env: - RUSTFLAGS: -C target-feature=+crt-static - run: cargo build --locked --release - - # On a tag only. A branch of this repository can read the secrets too, and - # the certificate is there to sign what is released, not what a branch - # built. The PFX lives in the runner's temporary folder, which nothing packs, - # for as long as signtool runs. - - name: Sign (Windows) - if: runner.os == 'Windows' && inputs.tag - shell: pwsh - env: - PFX_BASE64: ${{ secrets.SIGNTOOL_PFX_BASE64 }} - PFX_PASSWORD: ${{ secrets.SIGNTOOL_PFX_PASSWORD }} - run: | - Write-Host "::add-mask::$env:PFX_PASSWORD" - $signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" | - Sort-Object -Property @{ Expression = { try { [Version]$_.Directory.Parent.Name } catch { [Version]"0.0.0.0" } } } -Descending | - Select-Object -First 1 - if (-not $signtool) { throw "signtool.exe not found in the Windows Kits" } - $pfx = Join-Path $env:RUNNER_TEMP "signing.pfx" - try { - [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:PFX_BASE64)) - & $signtool.FullName sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /f $pfx /p $env:PFX_PASSWORD ` - target\release\waveflow-server.exe ffmpeg-out\ffmpeg.exe ffmpeg-out\ffprobe.exe - if ($LASTEXITCODE -ne 0) { throw "signtool exited with $LASTEXITCODE" } - } finally { - Remove-Item -LiteralPath $pfx -Force -ErrorAction SilentlyContinue - } - # Needs no secret, so every run does it and a pull request proves it. `-` is # ad hoc; a Developer ID would be this identity, with its certificate # imported first and the notarization of §6 after the archive. The linker diff --git a/docs/plans/standalone-download-plan.md b/docs/plans/standalone-download-plan.md index d007b7c8..00805dd6 100644 --- a/docs/plans/standalone-download-plan.md +++ b/docs/plans/standalone-download-plan.md @@ -344,7 +344,10 @@ project wants that later. uses. An Actions secret belongs to one repository, so both are set on this one too. A tag without them stops before any build. A pull request or a manual run signs nothing on Windows, even when the secrets are readable, so the - certificate only ever signs what is released. + certificate only ever signs what is released. The signing is a job of its own, + which checks out nothing and builds nothing: `cargo build` runs the build + scripts and procedural macros of every dependency, and a process one of them + left on the runner could read the PFX. - **macOS.** `codesign --force --sign -` with a stable identifier (`app.waveflow.server`, and `.ffmpeg` and `.ffprobe` for the tools) on all three executables of both targets, on every run, since it needs no secret. @@ -453,9 +456,12 @@ one that lacks the certificate of §6, before anything is built. Then, in order: what this page first proposed: what a release ships is compiled from its tag, and its build costs minutes, on a day that is rare. 2. **`webapp`** — the client, once, for all five. -3. **`archive`** — one native runner per target builds the server, signs the - three executables (§6), packs the archive of §2 with - `packaging/standalone/pack.sh`, and starts it with +3. **`windows-server`** and **`windows-sign`** — the Windows server is built, then + its three executables are signed on another runner (§6). The other targets + build in `archive`, having no certificate to keep from their build. +4. **`archive`** — one native runner per target builds the server and signs the + macOS executables, or takes the Windows ones from step 3, packs the archive of + §2 with `packaging/standalone/pack.sh`, and starts it with `packaging/standalone/smoke.sh`: the files of §2, nothing loaded that the system lacks, laptop mode from the `laptop.env` in the archive, and an empty `PATH`, so the tools can only be the ones beside it, then a FLAC file turned @@ -471,9 +477,9 @@ one that lacks the certificate of §6, before anything is built. Then, in order: (`+crt-static`): `VCRUNTIME140.dll` is not part of Windows, and a laptop that has never installed the redistributable would refuse the executable before it printed a line. -4. **`assets`** — one `SHA256SUMS` over the five archives and the source bundle, +5. **`assets`** — one `SHA256SUMS` over the five archives and the source bundle, and all seven files in one artifact. -5. **The release job** attaches that artifact, and the release notes gain a +6. **The release job** attaches that artifact, and the release notes gain a "Download" section above the image's. It runs last, and the image's tags now wait for the archives too, so a tag whose archives failed announces nothing. From faee2d675f2ec58b8bf3de8a647079683c5595bf Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 2 Nov 2026 01:00:07 +0100 Subject: [PATCH 3/3] ci(release): hold each signature to valid, not to unknown error UnknownError is what a self-signed certificate answers, and also what PowerShell answers for any failure it has no name for, so accepting it accepted those too. Put the one signer the three executables share into the runner's trusted roots, and require Valid of each. Also say what a missing secret really stops: the archives and the publishing, not the image's build beside them. And a manual retry of release.yml names its tag and signs; only a run with no tag does not. Signed-off-by: InstaZDLL --- .github/workflows/release.yml | 8 +++-- .github/workflows/standalone.yml | 44 +++++++++++++++----------- CHANGELOG.md | 5 +-- docs/plans/standalone-download-plan.md | 11 ++++--- 4 files changed, 41 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 965eac06..ea89a1ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,9 +22,11 @@ name: Release # # The Windows executables in the archives are signed with the certificate in the # `SIGNTOOL_PFX_BASE64` (the PFX, in base64) and `SIGNTOOL_PFX_PASSWORD` repository -# secrets; without them `standalone.yml` stops before it builds anything, and no -# release ships unsigned. The certificate is self-signed, so Windows still warns: -# §6 of docs/plans/standalone-download-plan.md says what it gives and what not. +# secrets. Without them `standalone.yml` stops before it builds an archive, and the +# image's tags and the release page, which wait for it, are never written; the +# image's own build still runs. The certificate is self-signed, so Windows still +# warns: §6 of docs/plans/standalone-download-plan.md says what it gives and what +# not. # # The tag is the source of truth for the version. `Cargo.toml` is not read # here — a tag that disagrees with it would produce an image labelled one way diff --git a/.github/workflows/standalone.yml b/.github/workflows/standalone.yml index 27e88ea5..c9d58b7f 100644 --- a/.github/workflows/standalone.yml +++ b/.github/workflows/standalone.yml @@ -58,9 +58,9 @@ concurrency: cancel-in-progress: ${{ !inputs.tag }} jobs: - # First, so a tag without its certificate stops before any build minute is - # spent, and no release can ship unsigned by accident. A run without a tag - # signs nothing on Windows, so it has nothing to check. + # First, so the archives of a tag without its certificate stop before they cost + # a build minute, and no release can ship unsigned by accident. A run without a + # tag signs nothing on Windows, so it has nothing to check. signing-secrets: name: Signing secrets runs-on: ubuntu-24.04 @@ -322,29 +322,37 @@ jobs: shell: bash run: sh packaging/standalone/smoke.sh dist/waveflow-server-* - # Read back from the archive, which is what ships. The certificate is - # self-signed, so its chain ends at a root Windows does not trust and the - # status is `UnknownError`, never `Valid`: what this holds to is that each - # file's hash matches (`HashMismatch` otherwise), that it has a signer and a - # timestamp, and that the three share one signer. Its thumbprint is public, - # and goes in the log for anyone comparing a download against it. + # Read back from the archive, which is what ships. A self-signed certificate + # answers `UnknownError`, its root being untrusted, but so does any failure + # PowerShell has no name for, so accepting that status would accept those too. + # Trust is taken out of the question instead: the one signer all three share + # goes into this runner's trusted roots, for this job's short life, and each + # file must then answer `Valid`. The thumbprint is public, and goes in the log + # for anyone comparing a download against it. - name: Check the signatures (Windows) if: runner.os == 'Windows' && inputs.tag shell: pwsh run: | $extracted = Join-Path $env:RUNNER_TEMP "signatures" Expand-Archive -Path dist\waveflow-server-*.zip -DestinationPath $extracted - $signers = foreach ($tool in "waveflow-server.exe", "ffmpeg.exe", "ffprobe.exe") { - $file = Get-ChildItem -Path $extracted -Recurse -Filter $tool + $files = foreach ($tool in "waveflow-server.exe", "ffmpeg.exe", "ffprobe.exe") { + Get-ChildItem -Path $extracted -Recurse -Filter $tool + } + $signers = @($files | ForEach-Object { (Get-AuthenticodeSignature -LiteralPath $_.FullName).SignerCertificate }) + if ($signers.Count -ne 3) { throw "only $($signers.Count) of the three executables have a signer" } + $thumbprints = @($signers.Thumbprint | Select-Object -Unique) + if ($thumbprints.Count -ne 1) { throw "the executables have different signers: $thumbprints" } + Write-Host "signed by $($signers[0].Subject), thumbprint $thumbprints" + $roots = [Security.Cryptography.X509Certificates.X509Store]::new("Root", "LocalMachine") + $roots.Open("ReadWrite") + $roots.Add($signers[0]) + $roots.Close() + foreach ($file in $files) { $signature = Get-AuthenticodeSignature -LiteralPath $file.FullName - $signer = $signature.SignerCertificate - Write-Host "${tool}: $($signature.Status), $($signer.Subject), $($signer.Thumbprint)" - if ($signature.Status.ToString() -notin "Valid", "UnknownError") { throw "${tool}: $($signature.StatusMessage)" } - if (-not $signer) { throw "${tool} has no signer" } - if (-not $signature.TimeStamperCertificate) { throw "${tool} has no timestamp" } - $signer.Thumbprint + Write-Host "$($file.Name): $($signature.Status)" + if ($signature.Status.ToString() -ne "Valid") { throw "$($file.Name): $($signature.StatusMessage)" } + if (-not $signature.TimeStamperCertificate) { throw "$($file.Name) has no timestamp" } } - if (@($signers | Select-Object -Unique).Count -ne 1) { throw "the executables have different signers: $signers" } # `--verify` alone would pass on the linker's signature of an arm64 binary, # had the signing above done nothing. The identifier is what only it wrote. diff --git a/CHANGELOG.md b/CHANGELOG.md index ed3e45fe..5bf8560f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,8 +51,9 @@ out first, whether or not it breaks a compile. are: with a self-signed Authenticode certificate on Windows, ad hoc on macOS. A file changed after signing no longer matches its signature, but neither system trusts these signatures, so the warnings of the first launch stay. A - release needs the `SIGNTOOL_PFX_BASE64` and `SIGNTOOL_PFX_PASSWORD` secrets and - stops before building without them. + release needs the `SIGNTOOL_PFX_BASE64` and `SIGNTOOL_PFX_PASSWORD` secrets: + without them the archives stop before they build and nothing is published, + though the image's build still runs. - **The transcoding state in the web client's administration**: whether the server can transcode (without it, listeners get the original files only), the transcodes running against the global limit, the per-account limit and diff --git a/docs/plans/standalone-download-plan.md b/docs/plans/standalone-download-plan.md index 00805dd6..2fb01c19 100644 --- a/docs/plans/standalone-download-plan.md +++ b/docs/plans/standalone-download-plan.md @@ -342,9 +342,12 @@ project wants that later. `ffprobe.exe`, timestamped, from the `SIGNTOOL_PFX_BASE64` (the PFX, in base64) and `SIGNTOOL_PFX_PASSWORD` repository secrets, the names the desktop uses. An Actions secret belongs to one repository, so both are set on this one - too. A tag without them stops before any build. A pull request or a manual run - signs nothing on Windows, even when the secrets are readable, so the - certificate only ever signs what is released. The signing is a job of its own, + too. Without them a tag's archives stop before they build and nothing is + published, though the image's build still runs beside them. A run with no tag, + a pull request or `standalone.yml` dispatched by hand, signs nothing on Windows + even when the secrets are readable, while a manual retry of `release.yml` names + its tag and signs, so the certificate only ever signs what is released. The + signing is a job of its own, which checks out nothing and builds nothing: `cargo build` runs the build scripts and procedural macros of every dependency, and a process one of them left on the runner could read the PFX. @@ -443,7 +446,7 @@ instead (below), which also covers the other platforms for anyone who checks. either publishes, so a pull request that touches `packaging/standalone/` or a manual dispatch runs the same jobs and stops at a workflow artifact, which is how they are exercised without a tag. First, on a tag, `signing-secrets` refuses -one that lacks the certificate of §6, before anything is built. Then, in order: +one that lacks the certificate of §6, before any archive is built. Then, in order: 1. **`ffmpeg`** — `ffmpeg.yml` as it stands: one native runner per target builds the minimal FFmpeg, runs the `media`, `quality` and `canvas` targets