diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index a4dfa5d7..1931a214 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -13,6 +13,8 @@ name: Release (macOS) # APPLE_TEAM_ID Apple Developer Team ID # CSC_LINK base64-encoded Developer ID Application .p12 # CSC_KEY_PASSWORD password for that .p12 +# EVS_ACCOUNT_NAME castlabs EVS account name (Widevine VMP signing; free signup) +# EVS_PASSWD password for that EVS account # GOOGLE_OAUTH_CLIENT_ID shipped in production .env (Google OAuth) # GOOGLE_OAUTH_CLIENT_SECRET shipped in production .env (Google OAuth) # @@ -70,6 +72,8 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + EVS_ACCOUNT_NAME: ${{ secrets.EVS_ACCOUNT_NAME }} + EVS_PASSWD: ${{ secrets.EVS_PASSWD }} PUBLISH_INPUT: ${{ github.event.inputs.publish }} steps: @@ -87,6 +91,14 @@ jobs: with: python-version: '3.13' + # Widevine VMP signing tool. The afterPack hook invokes `castlabs_evs.vmp + # sign-pkg` with the EVS_* secrets; without this the build aborts (publish + # path sets VMP_REQUIRE_SIGN=1) rather than ship a DMG with dead Spotify DRM. + - name: Install castlabs-evs (Widevine VMP signing) + if: ${{ env.APPLE_ID != '' }} + shell: bash + run: python3 -m pip install --upgrade castlabs-evs + - name: Build app # Skip (green) when Apple signing secrets aren't in CI: Mac ships via local # publish.sh, so a secret-less CI run should no-op, not fail red. diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 3fcc910a..c6d328e9 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -17,6 +17,8 @@ name: Release (Windows) # AZURE_SIGNING_ENDPOINT e.g. https://wus2.codesigning.azure.net/ # AZURE_SIGNING_ACCOUNT mist-code-signing # AZURE_SIGNING_CERT_PROFILE Mist-Windows-Signing +# EVS_ACCOUNT_NAME castlabs EVS account name (Widevine VMP signing; free signup) +# EVS_PASSWD password for that EVS account # GOOGLE_OAUTH_CLIENT_ID shipped in production .env (Google OAuth) # GOOGLE_OAUTH_CLIENT_SECRET shipped in production .env (Google OAuth) # v1.0.29 cloud-proxied the OAuth flow itself, @@ -77,6 +79,8 @@ jobs: AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_SIGNING_CERT_PROFILE: ${{ secrets.AZURE_SIGNING_CERT_PROFILE }} + EVS_ACCOUNT_NAME: ${{ secrets.EVS_ACCOUNT_NAME }} + EVS_PASSWD: ${{ secrets.EVS_PASSWD }} PUBLISH_INPUT: ${{ github.event.inputs.publish }} steps: @@ -96,6 +100,14 @@ jobs: with: python-version: '3.13' + # Widevine VMP signing tool. The afterPack hook invokes `castlabs_evs.vmp + # sign-pkg` with the EVS_* secrets; the -Sign path sets VMP_REQUIRE_SIGN=1 so + # a missing/failed signature aborts the build rather than ship an installer + # whose Spotify/Netflix audio is silently dead. + - name: Install castlabs-evs (Widevine VMP signing) + shell: pwsh + run: python -m pip install --upgrade castlabs-evs + # The signing hook calls `signtool.exe` directly. signtool ships in the # Windows 10 SDK, preinstalled on windows-latest runners — we just need # the dlib for Azure Trusted Signing, pulled via NuGet. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 10fb08ba..14bc0f71 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -37,6 +37,10 @@ platform. See `RELEASE_RUNBOOK.md` for the how; this is the gate. - [ ] macOS Intel (x64), macOS 12+: same. - [ ] Auto-update: previous stable installed → this release detected, downloads, installs on quit, relaunches on the new version. Verify on both platforms. +- [ ] Widevine DRM: in a Browser card open a Spotify playlist (or any DRM title) + and confirm a track plays PAST the ~10s encrypted boundary and auto-advances, + with no `[drm-diag] License response 500` in the logs. A signed-but-not-VMP + build boots fine and only fails here, so this box catches it. Both platforms. ## Promote - [ ] All boxes above ticked. diff --git a/electron/build/after-pack.js b/electron/build/after-pack.js index 95e4d621..1baaa072 100644 --- a/electron/build/after-pack.js +++ b/electron/build/after-pack.js @@ -11,8 +11,54 @@ // this rescue. const fs = require('fs'); const path = require('path'); +const { execFileSync } = require('child_process'); -exports.default = async function afterPack(context) { +// Widevine VMP signing of the PACKAGED app. Has to happen here in afterPack, not +// at npm-install time on node_modules: the OS code-sign electron-builder runs +// right after this seals the VMP signature into the bundle, so signing the source +// electron earlier gets stripped/relocated and Spotify's license server then 500s. +// Lenient by default (a dev `npm run dist` without an EVS account still produces an +// app, just with limited DRM); VMP_REQUIRE_SIGN=1 (set by the signed release paths) +// turns a missing/failed signature into a hard build failure so prod never ships +// an unsigned-for-DRM client silently. +function signVmp(context) { + const { appOutDir, electronPlatformName, packager } = context; + const required = process.env.VMP_REQUIRE_SIGN === '1'; + const acct = process.env.EVS_ACCOUNT_NAME; + const pass = process.env.EVS_PASSWD; + + if (!acct || !pass) { + if (required) { + throw new Error('[afterPack] VMP_REQUIRE_SIGN=1 but EVS_ACCOUNT_NAME/EVS_PASSWD are absent — refusing to ship a release whose Widevine DRM (Spotify/Netflix) would be dead'); + } + console.warn('[afterPack] EVS creds absent — skipping VMP signing; DRM playback will be limited (dev build)'); + return; + } + + // mac: sign the .app bundle; win: sign the unpacked dir holding the exe + framework. + const target = electronPlatformName === 'darwin' + ? path.join(appOutDir, `${packager.appInfo.productFilename}.app`) + : appOutDir; + const py = process.platform === 'win32' ? 'python' : 'python3'; + + try { + console.log(`[afterPack] VMP-signing ${target}`); + // Creds go via the environment (EVS reads EVS_ACCOUNT_NAME/EVS_PASSWD), never on + // the argv — a password in a command line is readable by any `ps` on the host. + execFileSync(py, ['-m', 'castlabs_evs.vmp', 'sign-pkg', target, '--no-ask'], { + stdio: 'inherit', + env: { ...process.env, EVS_ACCOUNT_NAME: acct, EVS_PASSWD: pass }, + }); + console.log('[afterPack] VMP signing successful — full DRM playback enabled'); + } catch (err) { + if (required) { + throw new Error(`[afterPack] VMP signing failed (release would have broken DRM): ${err && err.message}`); + } + console.warn(`[afterPack] VMP signing failed (non-fatal in dev): ${err && err.message}`); + } +} + +function stageRouterNodeModules(context) { const { appOutDir, electronPlatformName, packager } = context; const src = path.join(__dirname, '..', 'build-staging', 'router', 'node_modules'); if (!fs.existsSync(src)) return; // dev/no-router build; nothing to do @@ -34,4 +80,11 @@ exports.default = async function afterPack(context) { throw new Error(`afterPack: 9Router node_modules/next missing in ${routerDir} after copy`); } console.log(`[afterPack] staged 9Router node_modules into ${routerDir}`); +} + +exports.default = async function afterPack(context) { + stageRouterNodeModules(context); + // VMP signing runs last and unconditionally, after every file is staged, so the + // OS code-sign that electron-builder runs next seals the VMP signature too. + signVmp(context); }; diff --git a/electron/scripts/sign-vmp.sh b/electron/scripts/sign-vmp.sh index ba5eb833..a96fdaa7 100755 --- a/electron/scripts/sign-vmp.sh +++ b/electron/scripts/sign-vmp.sh @@ -42,6 +42,14 @@ if ! python3 -c "import castlabs_evs" 2>/dev/null; then exit 0 fi +# When creds are in the env (EVS reads EVS_ACCOUNT_NAME/EVS_PASSWD itself), go +# non-interactive so CI / non-TTY runs don't hang on a prompt. Creds stay in the +# environment, never on the argv where any `ps` on the host could read them. +EVS_AUTH=() +if [ -n "${EVS_ACCOUNT_NAME:-}" ] && [ -n "${EVS_PASSWD:-}" ]; then + EVS_AUTH=(--no-ask) +fi + VERIFY_OUTPUT=$(python3 -m castlabs_evs.vmp verify-pkg "$ELECTRON_DIR" 2>&1) if echo "$VERIFY_OUTPUT" | grep -q "Signature is valid" && ! echo "$VERIFY_OUTPUT" | grep -q "development only"; then echo "[vmp] Electron already has a valid production VMP signature" @@ -49,7 +57,7 @@ if echo "$VERIFY_OUTPUT" | grep -q "Signature is valid" && ! echo "$VERIFY_OUTPU fi echo "[vmp] Signing Electron with production VMP certificate..." -if python3 -m castlabs_evs.vmp sign-pkg "$ELECTRON_DIR" 2>&1; then +if python3 -m castlabs_evs.vmp sign-pkg "$ELECTRON_DIR" "${EVS_AUTH[@]}" 2>&1; then echo "[vmp] VMP signing successful — full DRM playback enabled" # Re-fix symlinks in case signing modified the bundle fix_framework_symlinks diff --git a/scripts/build-app-win.ps1 b/scripts/build-app-win.ps1 index 2755e678..595ce617 100644 --- a/scripts/build-app-win.ps1 +++ b/scripts/build-app-win.ps1 @@ -70,6 +70,10 @@ if ($Sign) { Write-Host "Copy .env.windows.example to .env.windows and fill in values." exit 1 } + # A signed build is one users actually run, so its Widevine VMP signature is + # mandatory: the afterPack hook hard-fails on a missing/failed signature rather + # than ship an installer whose Spotify/Netflix audio is silently dead. + $env:VMP_REQUIRE_SIGN = '1' } # --- Step 0: Bundled uv + uvx for Windows --- diff --git a/scripts/build-app.sh b/scripts/build-app.sh index cb51c573..5fcaaaa3 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -66,6 +66,10 @@ if $SIGN_MODE; then echo "See script header for details." exit 1 fi + # A signed build is a build users actually run, so its Widevine VMP signature + # is mandatory: the afterPack hook hard-fails on a missing/failed signature + # instead of shipping a DMG whose Spotify/Netflix audio is silently dead. + export VMP_REQUIRE_SIGN=1 fi # Step 0: Ensure bundled uv + uvx binaries exist.