diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 656526b2..ea89a1ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,14 @@ 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 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 # and reporting itself another, so the two are checked against each other @@ -116,14 +124,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 +369,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..c9d58b7f 100644 --- a/.github/workflows/standalone.yml +++ b/.github/workflows/standalone.yml @@ -18,6 +18,13 @@ 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. +# - 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: @@ -26,6 +33,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 +58,32 @@ concurrency: cancel-in-progress: ${{ !inputs.tag }} jobs: + # 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 + 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 +93,7 @@ jobs: # catches. webapp: name: Web client + needs: signing-secrets runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -78,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: @@ -106,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 @@ -135,7 +275,7 @@ jobs: ' - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # stable - if: runner.os != 'Linux' + if: runner.os == 'macOS' with: toolchain: "1.98" @@ -145,11 +285,19 @@ jobs: MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macos-deployment-target }} run: cargo build --locked --release - - name: Build (Windows) - if: runner.os == 'Windows' + # 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: - RUSTFLAGS: -C target-feature=+crt-static - run: cargo build --locked --release + 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 @@ -174,6 +322,59 @@ jobs: shell: bash run: sh packaging/standalone/smoke.sh dist/waveflow-server-* + # 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 + $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 + 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" } + } + + # `--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..5bf8560f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,13 +40,20 @@ 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: + 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/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..2fb01c19 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,100 @@ 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. 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. +- **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 +445,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 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 @@ -391,13 +459,18 @@ 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. **`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 - 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**, @@ -407,9 +480,9 @@ how they are exercised without a tag. 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. @@ -449,7 +522,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.